diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-05 20:18:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-05 20:19:13 +0300 |
| commit | 220b99a9fabf10fad83a570f641ad39737d85875 (patch) | |
| tree | a27a3b791f110d091ca6ef9b194bb6c1fb4e2b55 | |
| parent | 2bc1724c2dae72184a5d2ec070e30c70dc519f9b (diff) | |
fix: support custom command keybindings on windows
refs #1041
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 4 | ||||
| -rw-r--r-- | scripts/test_vendor_portable_pty.py | 13 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 6 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/pane.rs | 4 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 17 | ||||
| -rw-r--r-- | src/platform/linux.rs | 17 | ||||
| -rw-r--r-- | src/platform/macos.rs | 17 | ||||
| -rw-r--r-- | src/platform/mod.rs | 31 | ||||
| -rw-r--r-- | src/platform/windows.rs | 107 | ||||
| -rw-r--r-- | vendor/patches/portable-pty/0002-windows-raw-command-tail.patch | 116 | ||||
| -rw-r--r-- | vendor/portable-pty.patches.md | 34 | ||||
| -rw-r--r-- | vendor/portable-pty/src/cmdbuilder.rs | 49 |
15 files changed, 410 insertions, 9 deletions
@@ -28,7 +28,7 @@ These instructions are layered. - **State is separated from runtime.** `AppState` is pure data, testable without PTYs or async. `PaneState` is separate from `PaneRuntime`. Workspace logic doesn't need real terminals. - **Render is pure.** `compute_view()` handles geometry and mutations. `render()` takes `&AppState` and only draws. Never mutate state during render. - **No god objects.** If a module is doing too many things, split it. `app/` is already split into state, actions, and input. Keep it that way. -- **Platform code is isolated.** OS-specific behavior lives in `src/platform/`. Core modules don't have `#[cfg(target_os)]`. +- **Platform code is isolated.** OS-specific behavior lives in the matching `src/platform/<os>.rs` file, with only shared traits, types, wrappers, and testable contracts in `src/platform/mod.rs`. Core modules don't have `#[cfg(target_os)]`. - **Detection is decoupled.** The detector reads a screen snapshot, never touches the parser or viewport state. - **Screen detection is evidence-based.** When changing `src/detect/manifests/`, first capture the relevant bottom-buffer state with `herdr agent read <pane> --source detection --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it. - **UI patterns should be reused.** Herdr is a mouse-first TUI. New dialogs, onboarding, settings, and post-update flows should follow the existing UI/UX language and interaction patterns instead of inventing one-off screens. Prefer reusing existing modal/screen structure, affordances, and close actions so the app feels consistent. diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index cf684726..22d74383 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixed - `herdr server stop` now waits until both server sockets are unreachable before returning, avoiding an immediate first-start failure when restarting right after replacing the binary. - Unix local Herdr clients no longer treat empty bracketed paste as a clipboard-image bridge; `herdr --remote` keeps using it for local-desktop image paste over SSH. (#986) +- Custom command keybindings now run through `cmd.exe /d /c` on Windows instead of `/bin/sh`, so `type = "pane"` and `type = "shell"` bindings can launch native Windows commands. (#1041) - Plain PageUp/PageDown now reach primary-screen pager apps such as `less -X` and Git diff when they enter application cursor mode, while shell transcripts still use Herdr pane scrollback. (#953) - `prefix+e` scrollback editor panes now open on Windows without trying to run `/bin/sh`; Windows uses `VISUAL`, then `EDITOR`, then `notepad.exe` as the fallback editor. (#914) - `herdr pane split --current` now resolves to the calling Herdr pane instead of the UI-focused pane when run inside a pane. (#902) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index b17107df..8f411cba 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -71,7 +71,7 @@ Set the executable Herdr uses for newly created interactive panes: default_shell = "nu" ``` -When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable name or path, not a shell command line. Existing panes keep their current shell until they are recreated. Command panes still run through `/bin/sh -c`; detached custom command keybindings use Herdr's existing `/bin/sh -lc` path. +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`. Set how Herdr starts newly created interactive pane shells: @@ -248,6 +248,8 @@ An optional `description` can be provided. When specified, this description is d Custom commands receive `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`, `HERDR_ACTIVE_WORKSPACE_ID`, `HERDR_ACTIVE_TAB_ID`, `HERDR_ACTIVE_PANE_ID`, and `HERDR_ACTIVE_PANE_CWD` when those values are available. Shell commands run from the focused pane's working directory when Herdr can detect it. +On Windows, custom command strings use `cmd.exe /d /c`, so environment variables use `%HERDR_BIN_PATH%` syntax. To run PowerShell syntax, invoke it explicitly, for example `powershell.exe -NoProfile -Command "..."`. + ## Theme Choose a built-in theme: diff --git a/scripts/test_vendor_portable_pty.py b/scripts/test_vendor_portable_pty.py index 8a6d4ca1..87537d41 100644 --- a/scripts/test_vendor_portable_pty.py +++ b/scripts/test_vendor_portable_pty.py @@ -71,6 +71,19 @@ class VendorPortablePtyTests(unittest.TestCase): ] self.assertEqual(missing, []) + def test_listed_local_vendor_patches_exist(self) -> None: + project_root = Path(__file__).resolve().parent.parent + index = project_root / "vendor" / "portable-pty.patches.md" + text = index.read_text() + listed = [ + line.split("`", 2)[1] + for line in text.splitlines() + if line.startswith("patch: `vendor/patches/portable-pty/") + ] + + missing = [path for path in listed if not (project_root / path).exists()] + self.assertEqual(missing, []) + def test_local_vendor_patches_are_applied_to_vendored_tree(self) -> None: project_root = Path(__file__).resolve().parent.parent patch_dir = project_root / "vendor" / "patches" / "portable-pty" diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index b278daac..f4719ace 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1,7 +1,7 @@ use std::{ fs, io, io::Write, - process::{Command, Stdio}, + process::Stdio, time::{SystemTime, UNIX_EPOCH}, }; @@ -771,10 +771,8 @@ impl App { &self, binding: &crate::config::CustomCommandKeybind, ) -> std::io::Result<()> { - let mut command = Command::new("/bin/sh"); + let mut command = crate::platform::detached_custom_command_process(&binding.command); command - .arg("-lc") - .arg(&binding.command) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); diff --git a/src/main.rs b/src/main.rs index 04ff4894..3b4bed1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Custom commands use the same binding syntax. # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. +# On Windows, command strings run through cmd.exe /d /c. # [[keys.command]] # key = "prefix+alt+g" # type = "pane" diff --git a/src/pane.rs b/src/pane.rs index 6094af83..a6edaaa7 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1585,9 +1585,7 @@ impl PaneRuntime { render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { - let mut cmd = CommandBuilder::new("/bin/sh"); - cmd.arg("-c"); - cmd.arg(command); + let mut cmd = crate::platform::pane_custom_command_pty_builder(command); cmd.cwd(cwd); apply_pane_terminal_env(&mut cmd); apply_pane_launch_env(&mut cmd, launch_env); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index e9134a8b..382ccf5e 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -6,6 +6,23 @@ use super::{ClipboardImage, ForegroundJob, Signal}; /// Unsupported platform stub. pub fn raise_server_nofile_limit() {} +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + /// Unsupported platform stub. pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result<Vec<String>> { Err(std::io::Error::new( diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 4e5574d0..be0c97b9 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -39,6 +39,23 @@ static FOREGROUND_MEMBERS_CACHE: Mutex<ForegroundMembersCache> = pub fn raise_server_nofile_limit() {} +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> { let quoted_path = shell_quote(&path.display().to_string()); let command = format!( diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e72d32fe..f2441f06 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -16,6 +16,23 @@ const PROC_PGRP_ONLY: u32 = 2; const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192; const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; +fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> { + vec!["/bin/sh".into(), flag.into(), command.into()] +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + let argv = raw_command_argv(command, "-lc"); + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]); + command +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) +} + pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result<Vec<String>> { let quoted_path = shell_quote(&path.display().to_string()); let command = format!( diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 889c4ce9..e396966b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -25,6 +25,14 @@ pub enum Signal { Kill, } +pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command { + detached_custom_command_process_platform(command) +} + +pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::CommandBuilder { + pane_custom_command_pty_builder_platform(command) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlatformCapabilities { pub(crate) live_handoff: bool, @@ -199,6 +207,29 @@ mod tests { use super::*; #[test] + fn detached_custom_command_preserves_unix_login_shell_flag() { + let cmd = detached_custom_command_process("echo hello"); + assert_eq!(cmd.get_program(), std::ffi::OsStr::new("/bin/sh")); + assert_eq!( + cmd.get_args().collect::<Vec<_>>(), + [ + std::ffi::OsStr::new("-lc"), + std::ffi::OsStr::new("echo hello") + ] + ); + } + + #[test] + fn pane_custom_command_builder_preserves_unix_shell_flag() { + let expected: Vec<std::ffi::OsString> = + vec!["/bin/sh".into(), "-c".into(), "echo hello".into()]; + assert_eq!( + pane_custom_command_pty_builder("echo hello").get_argv(), + &expected + ); + } + + #[test] fn read_limited_reader_returns_complete_data_under_limit() { let input = std::io::Cursor::new(b"image".to_vec()); assert_eq!( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 50e0e905..cc486ace 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -46,6 +46,44 @@ struct WindowsProcessEntry { pub fn raise_server_nofile_limit() {} +fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString { + comspec + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) +} + +pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { + detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) +} + +fn detached_custom_command_process_with_comspec( + command: &str, + comspec: Option<std::ffi::OsString>, +) -> std::process::Command { + use std::os::windows::process::CommandExt; + + let mut process = std::process::Command::new(raw_command_shell(comspec)); + process.arg("/d").arg("/c").raw_arg(command); + process +} + +pub(crate) fn pane_custom_command_pty_builder_platform( + command: &str, +) -> portable_pty::CommandBuilder { + pane_custom_command_pty_builder_with_comspec(command, std::env::var_os("ComSpec")) +} + +fn pane_custom_command_pty_builder_with_comspec( + command: &str, + comspec: Option<std::ffi::OsString>, +) -> portable_pty::CommandBuilder { + let mut builder = portable_pty::CommandBuilder::new(raw_command_shell(comspec)); + builder.arg("/d"); + builder.arg("/c"); + builder.raw_arg(command); + builder +} + pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> { let editor = std::env::var("VISUAL") .ok() @@ -537,6 +575,75 @@ mod tests { time::{Duration, Instant}, }; + fn argv_strings(argv: &[std::ffi::OsString]) -> Vec<String> { + argv.into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn pane_custom_command_uses_cmd() { + let builder = super::pane_custom_command_pty_builder_with_comspec( + "echo hello", + Some(r"C:\Windows\System32\cmd.exe".into()), + ); + + assert_eq!( + argv_strings(builder.get_argv()), + [r"C:\Windows\System32\cmd.exe", "/d", "/c"] + ); + } + + #[test] + fn detached_custom_command_uses_cmd() { + let expected_shell = std::env::var_os("ComSpec") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) + .to_string_lossy() + .into_owned(); + + let process = super::detached_custom_command_process_platform("echo hello"); + + assert_eq!(process.get_program().to_string_lossy(), expected_shell); + assert_eq!( + process + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(), + ["/d", "/c", "echo hello"] + ); + } + + #[test] + fn custom_command_falls_back_when_comspec_is_empty() { + let builder = + super::pane_custom_command_pty_builder_with_comspec("echo hello", Some("".into())); + + assert_eq!( + argv_strings(builder.get_argv()), + [r"C:\Windows\System32\cmd.exe", "/d", "/c"] + ); + } + + #[test] + fn detached_custom_command_preserves_quoted_command_tail() { + let path = std::env::temp_dir().join(format!( + "herdr-raw-command-quotes-{}.txt", + std::process::id() + )); + let command = format!(r#"echo "hi" > "{}""#, path.display()); + + let status = super::detached_custom_command_process_platform(&command) + .status() + .expect("spawn raw command"); + + assert!(status.success(), "{status:?}"); + let content = std::fs::read_to_string(&path).expect("read command output"); + let _ = std::fs::remove_file(&path); + assert!(content.contains(r#""hi""#), "{content:?}"); + assert!(!content.contains(r#"\"hi\""#), "{content:?}"); + } + #[test] fn windows_process_cwd_reads_child_launch_directory() { let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id())); diff --git a/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch b/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch new file mode 100644 index 00000000..33e09804 --- /dev/null +++ b/vendor/patches/portable-pty/0002-windows-raw-command-tail.patch @@ -0,0 +1,116 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers <maintainers@herdr.dev> +Date: Sun, 5 Jul 2026 00:00:00 +0000 +Subject: [PATCH] expose Windows raw command tails + +Herdr launches user-authored custom command strings through the platform shell. +On Windows, cmd.exe expects the text after /c to remain shell syntax, but +portable-pty quotes every argv item with ArgvQuote. That escapes embedded +quotes in the command tail and changes cmd.exe parsing. + +Herdr issue: https://github.com/ogulcancelik/herdr/issues/1041 +Vendored base: portable-pty 0.9.0 +--- + vendor/portable-pty/src/cmdbuilder.rs | 40 +++++++++++++++++++++++++++ + 1 file changed, 40 insertions(+) + +diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs +index 0000000..0000000 100644 +--- a/vendor/portable-pty/src/cmdbuilder.rs ++++ b/vendor/portable-pty/src/cmdbuilder.rs +@@ -202,6 +202,8 @@ fn get_base_env() -> BTreeMap<OsString, EnvEntry> { + #[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] + pub struct CommandBuilder { + args: Vec<OsString>, ++ #[cfg(windows)] ++ raw_args: Vec<OsString>, + envs: BTreeMap<OsString, EnvEntry>, + cwd: Option<OsString>, + #[cfg(unix)] +@@ -215,6 +217,8 @@ impl CommandBuilder { + pub fn new<S: AsRef<OsStr>>(program: S) -> Self { + Self { + args: vec![program.as_ref().to_owned()], ++ #[cfg(windows)] ++ raw_args: Vec::new(), + envs: get_base_env(), + cwd: None, + #[cfg(unix)] +@@ -227,6 +231,8 @@ impl CommandBuilder { + pub fn from_argv(args: Vec<OsString>) -> Self { + Self { + args, ++ #[cfg(windows)] ++ raw_args: Vec::new(), + envs: get_base_env(), + cwd: None, + #[cfg(unix)] +@@ -254,6 +260,8 @@ impl CommandBuilder { + pub fn new_default_prog() -> Self { + Self { + args: vec![], ++ #[cfg(windows)] ++ raw_args: Vec::new(), + envs: get_base_env(), + cwd: None, + #[cfg(unix)] +@@ -287,6 +295,15 @@ impl CommandBuilder { + } + } + ++ /// Append raw shell text to the Windows command line without ArgvQuote escaping. ++ #[cfg(windows)] ++ pub fn raw_arg<S: AsRef<OsStr>>(&mut self, arg: S) { ++ if self.is_default_prog() { ++ panic!("attempted to add args to a default_prog builder"); ++ } ++ self.raw_args.push(arg.as_ref().to_owned()); ++ } ++ + pub fn get_argv(&self) -> &Vec<OsString> { + &self.args + } +@@ -692,6 +709,15 @@ impl CommandBuilder { + ); + Self::append_quoted(arg, &mut cmdline); + } ++ for arg in &self.raw_args { ++ cmdline.push(' ' as u16); ++ anyhow::ensure!( ++ !arg.encode_wide().any(|c| c == 0), ++ "invalid encoding for raw command line argument {:?}", ++ arg ++ ); ++ cmdline.extend(arg.encode_wide()); ++ } + // Ensure that the command line is nul terminated too! + cmdline.push(0); + Ok((exe, cmdline)) +@@ -820,4 +846,27 @@ mod tests { + cmd.env_remove("cARGO_pKG_aUTHORS"); + assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none()); + } ++ ++ #[cfg(windows)] ++ #[test] ++ fn raw_arg_appends_unescaped_windows_command_tail() { ++ use std::os::windows::ffi::OsStringExt; ++ ++ let mut cmd = CommandBuilder::new("cmd.exe"); ++ cmd.arg("/d"); ++ cmd.arg("/c"); ++ cmd.raw_arg(r#"echo "hi""#); ++ ++ let (_exe, cmdline) = cmd.cmdline().expect("command line"); ++ let nul = cmdline ++ .iter() ++ .position(|ch| *ch == 0) ++ .expect("command line should be nul terminated"); ++ let command = OsString::from_wide(&cmdline[..nul]) ++ .to_string_lossy() ++ .into_owned(); ++ ++ assert!(command.ends_with(r#"/d /c echo "hi""#), "{}", command); ++ assert!(!command.contains(r#"\"hi\""#), "{}", command); ++ } + } diff --git a/vendor/portable-pty.patches.md b/vendor/portable-pty.patches.md index 61645310..7e6d2743 100644 --- a/vendor/portable-pty.patches.md +++ b/vendor/portable-pty.patches.md @@ -39,3 +39,37 @@ python3 -m unittest scripts.test_vendor_portable_pty On Windows, also verify that pane creation succeeds when `PATH` contains a directory with `conpty.dll`. + +## 0002 expose Windows raw command tails + +status: active + +patch: `vendor/patches/portable-pty/0002-windows-raw-command-tail.patch` + +herdr issue: https://github.com/ogulcancelik/herdr/issues/1041 + +upstream discussion: none + +upstream pr: none + +vendored base: `portable-pty 0.9.0` + +local files: + +- `vendor/portable-pty/src/cmdbuilder.rs` + +reason: Herdr needs to launch `cmd.exe /d /c` with the user-authored command +tail parsed as shell text. `portable-pty` represents commands as argv and +ArgvQuote escapes embedded quotes, which changes how `cmd.exe` parses the raw +command string. + +remove when: upstream `portable-pty` exposes Windows raw command-line tail +support or Herdr replaces this launch path. + +verification: + +```sh +python3 -m unittest scripts.test_vendor_portable_pty +``` + +On Windows, also run `cargo test raw_arg_appends_unescaped_windows_command_tail`. diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs index b4ce50a8..385fe81c 100644 --- a/vendor/portable-pty/src/cmdbuilder.rs +++ b/vendor/portable-pty/src/cmdbuilder.rs @@ -202,6 +202,8 @@ fn get_base_env() -> BTreeMap<OsString, EnvEntry> { #[cfg_attr(feature = "serde_support", derive(Serialize, Deserialize))] pub struct CommandBuilder { args: Vec<OsString>, + #[cfg(windows)] + raw_args: Vec<OsString>, envs: BTreeMap<OsString, EnvEntry>, cwd: Option<OsString>, #[cfg(unix)] @@ -215,6 +217,8 @@ impl CommandBuilder { pub fn new<S: AsRef<OsStr>>(program: S) -> Self { Self { args: vec![program.as_ref().to_owned()], + #[cfg(windows)] + raw_args: Vec::new(), envs: get_base_env(), cwd: None, #[cfg(unix)] @@ -227,6 +231,8 @@ impl CommandBuilder { pub fn from_argv(args: Vec<OsString>) -> Self { Self { args, + #[cfg(windows)] + raw_args: Vec::new(), envs: get_base_env(), cwd: None, #[cfg(unix)] @@ -254,6 +260,8 @@ impl CommandBuilder { pub fn new_default_prog() -> Self { Self { args: vec![], + #[cfg(windows)] + raw_args: Vec::new(), envs: get_base_env(), cwd: None, #[cfg(unix)] @@ -287,6 +295,15 @@ impl CommandBuilder { } } + /// Append raw shell text to the Windows command line without ArgvQuote escaping. + #[cfg(windows)] + pub fn raw_arg<S: AsRef<OsStr>>(&mut self, arg: S) { + if self.is_default_prog() { + panic!("attempted to add args to a default_prog builder"); + } + self.raw_args.push(arg.as_ref().to_owned()); + } + pub fn get_argv(&self) -> &Vec<OsString> { &self.args } @@ -692,6 +709,15 @@ impl CommandBuilder { ); Self::append_quoted(arg, &mut cmdline); } + for arg in &self.raw_args { + cmdline.push(' ' as u16); + anyhow::ensure!( + !arg.encode_wide().any(|c| c == 0), + "invalid encoding for raw command line argument {:?}", + arg + ); + cmdline.extend(arg.encode_wide()); + } // Ensure that the command line is nul terminated too! cmdline.push(0); Ok((exe, cmdline)) @@ -820,4 +846,27 @@ mod tests { cmd.env_remove("cARGO_pKG_aUTHORS"); assert!(cmd.get_env("CARGO_PKG_AUTHORS").is_none()); } + + #[cfg(windows)] + #[test] + fn raw_arg_appends_unescaped_windows_command_tail() { + use std::os::windows::ffi::OsStringExt; + + let mut cmd = CommandBuilder::new("cmd.exe"); + cmd.arg("/d"); + cmd.arg("/c"); + cmd.raw_arg(r#"echo "hi""#); + + let (_exe, cmdline) = cmd.cmdline().expect("command line"); + let nul = cmdline + .iter() + .position(|ch| *ch == 0) + .expect("command line should be nul terminated"); + let command = OsString::from_wide(&cmdline[..nul]) + .to_string_lossy() + .into_owned(); + + assert!(command.ends_with(r#"/d /c echo "hi""#), "{}", command); + assert!(!command.contains(r#"\"hi\""#), "{}", command); + } } |
