aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/fallback.rs8
-rw-r--r--src/platform/linux.rs8
-rw-r--r--src/platform/macos.rs8
-rw-r--r--src/platform/mod.rs116
-rw-r--r--src/platform/windows.rs188
5 files changed, 328 insertions, 0 deletions
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs
index 6b5700cc..30bea5eb 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -27,6 +27,10 @@ pub(crate) fn pane_custom_command_pty_builder_platform(
portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c"))
}
+pub(crate) fn interactive_shell_command(_argv: &[String], _shell_name: &str) -> Option<String> {
+ None
+}
+
/// Unsupported platform stub.
pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result<Vec<String>> {
Err(std::io::Error::new(
@@ -43,6 +47,10 @@ pub fn current_process_is_detached_server_daemon() -> bool {
false
}
+pub(crate) fn available_pane_shell(_child_pid: u32) -> Option<String> {
+ None
+}
+
/// Unsupported platform stub.
pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> {
None
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 48f1ef1c..6557b41b 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -70,6 +70,10 @@ pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<
Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command])
}
+pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option<String> {
+ super::interactive_unix_shell_command(argv, shell_name, shell_quote)
+}
+
fn shell_quote(value: &str) -> String {
if !value.is_empty()
&& value.chars().all(|ch| {
@@ -87,6 +91,10 @@ fn shell_quote(value: &str) -> String {
}
/// Collect the foreground terminal job for a given child PID.
+pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
+ super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?)
+}
+
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
let tpgid = foreground_process_group_id(child_pid)?;
let members = foreground_process_group_members(child_pid, tpgid)?;
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index f35fd1eb..161354d7 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -45,6 +45,10 @@ pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result<Vec<String>
Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command])
}
+pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option<String> {
+ super::interactive_unix_shell_command(argv, shell_name, shell_quote)
+}
+
fn shell_quote(value: &str) -> String {
if !value.is_empty()
&& value.chars().all(|ch| {
@@ -315,6 +319,10 @@ fn target_nofile_soft_limit(
(current < target).then_some(target)
}
+pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
+ super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?)
+}
+
/// Collect the foreground terminal job for a given child PID.
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
if child_pid == 0 {
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 82a6704d..1415181e 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -157,6 +157,90 @@ mod fallback;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub use fallback::*;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn available_pane_shell_from_job(child_pid: u32, job: ForegroundJob) -> Option<String> {
+ if job.process_group_id != child_pid
+ || job.processes.iter().any(|process| process.pid != child_pid)
+ {
+ return None;
+ }
+ job.processes
+ .into_iter()
+ .find(|process| process.pid == child_pid)
+ .map(|process| process.name)
+ .filter(|name| is_pane_shell_process_name(name))
+}
+
+fn normalized_process_name(name: &str) -> String {
+ name.rsplit(['/', '\\'])
+ .next()
+ .unwrap_or(name)
+ .trim_start_matches('-')
+ .trim_end_matches(".exe")
+ .to_ascii_lowercase()
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn is_powershell_process_name(name: &str) -> bool {
+ matches!(
+ normalized_process_name(name).as_str(),
+ "pwsh" | "powershell"
+ )
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn interactive_unix_shell_command(
+ argv: &[String],
+ shell_name: &str,
+ quote_posix_arg: fn(&str) -> String,
+) -> Option<String> {
+ let quote = if is_powershell_process_name(shell_name) {
+ quote_powershell_arg
+ } else {
+ quote_posix_arg
+ };
+ let mut parts = argv.iter();
+ let mut command = quote(parts.next()?);
+ for part in parts {
+ command.push(' ');
+ command.push_str(&quote(part));
+ }
+ Some(command)
+}
+
+pub(crate) fn quote_powershell_arg(value: &str) -> String {
+ if !value.is_empty()
+ && value.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric()
+ || matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'+' | b'=')
+ })
+ {
+ return value.to_string();
+ }
+ format!("'{}'", value.replace('\'', "''"))
+}
+
+pub(crate) fn is_pane_shell_process_name(name: &str) -> bool {
+ let normalized = normalized_process_name(name);
+ matches!(
+ normalized.as_str(),
+ "sh" | "bash"
+ | "dash"
+ | "zsh"
+ | "fish"
+ | "ksh"
+ | "mksh"
+ | "csh"
+ | "tcsh"
+ | "elvish"
+ | "xonsh"
+ | "nu"
+ | "pwsh"
+ | "powershell"
+ | "cmd"
+ )
+}
+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn process_agent_hint(_pid: u32) -> Option<crate::detect::Agent> {
None
@@ -227,6 +311,16 @@ mod tests {
use super::*;
#[test]
+ fn pane_shell_process_names_reject_exec_replacement_programs() {
+ for shell in ["bash", "-zsh", "/bin/fish", "pwsh", "powershell.exe"] {
+ assert!(is_pane_shell_process_name(shell), "{shell}");
+ }
+ for program in ["vim", "nvim", "cargo", "test-runner", "opencode"] {
+ assert!(!is_pane_shell_process_name(program), "{program}");
+ }
+ }
+
+ #[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"));
@@ -269,6 +363,28 @@ mod tests {
assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None);
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn interactive_shell_command_quotes_for_posix_and_powershell() {
+ let argv = vec![
+ "pi".into(),
+ String::new(),
+ "two words".into(),
+ "a'b".into(),
+ "$HOME".into(),
+ "semi;colon".into(),
+ "@options".into(),
+ ];
+ assert_eq!(
+ interactive_shell_command(&argv, "bash").as_deref(),
+ Some("pi '' 'two words' 'a'\\''b' '$HOME' 'semi;colon' @options")
+ );
+ assert_eq!(
+ interactive_shell_command(&argv, "pwsh").as_deref(),
+ Some("pi '' 'two words' 'a''b' '$HOME' 'semi;colon' '@options'")
+ );
+ }
+
#[test]
fn read_limited_reader_returns_complete_data_under_limit() {
let input = std::io::Cursor::new(b"image".to_vec());
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 24de21ad..ef41e662 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -62,6 +62,71 @@ fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString
.unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into())
}
+pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option<String> {
+ let shell_name = shell_name.to_ascii_lowercase();
+ let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh");
+ let script = powershell_agent_script(argv)?;
+ if powershell {
+ Some(script)
+ } else {
+ Some(cmd_encoded_powershell_command(&script))
+ }
+}
+
+fn powershell_agent_script(argv: &[String]) -> Option<String> {
+ let (program, args) = argv.split_first()?;
+ let command_line = args
+ .iter()
+ .map(|arg| quote_windows_command_line_arg(arg))
+ .collect::<Vec<_>>()
+ .join(" ");
+ Some(format!(
+ "$p=Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru",
+ super::quote_powershell_arg(program),
+ super::quote_powershell_arg(&command_line),
+ ))
+}
+
+fn quote_windows_command_line_arg(value: &str) -> String {
+ if !value.is_empty()
+ && !value
+ .chars()
+ .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"'))
+ {
+ return value.to_string();
+ }
+
+ let mut quoted = String::from("\"");
+ let mut backslashes = 0;
+ for ch in value.chars() {
+ if ch == '\\' {
+ backslashes += 1;
+ continue;
+ }
+ if ch == '"' {
+ quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
+ } else {
+ quoted.push_str(&"\\".repeat(backslashes));
+ }
+ backslashes = 0;
+ quoted.push(ch);
+ }
+ quoted.push_str(&"\\".repeat(backslashes * 2));
+ quoted.push('"');
+ quoted
+}
+
+fn cmd_encoded_powershell_command(script: &str) -> String {
+ use base64::Engine as _;
+
+ let utf16 = script
+ .encode_utf16()
+ .flat_map(u16::to_le_bytes)
+ .collect::<Vec<_>>();
+ let encoded = base64::engine::general_purpose::STANDARD.encode(utf16);
+ format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}")
+}
+
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"))
}
@@ -155,6 +220,23 @@ pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
select_pane_foreground_job(child_pid, &entries)
}
+pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
+ available_pane_shell_from_snapshot(child_pid, &snapshot_processes())
+}
+
+fn available_pane_shell_from_snapshot(
+ child_pid: u32,
+ entries: &[WindowsProcessEntry],
+) -> Option<String> {
+ let shell = entries.iter().find(|entry| entry.pid == child_pid)?;
+ if !super::is_pane_shell_process_name(&shell.name) {
+ return None;
+ }
+ descendant_entries(child_pid, entries)
+ .is_empty()
+ .then(|| shell.name.clone())
+}
+
pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
let entries = snapshot_processes();
let entry = entries.iter().find(|entry| entry.pid == process_group_id)?;
@@ -670,6 +752,91 @@ mod tests {
AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow,
};
+ #[test]
+ fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() {
+ use base64::Engine as _;
+
+ assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'");
+ let argv = vec![
+ "pi".into(),
+ String::new(),
+ "two words".into(),
+ "100%".into(),
+ "wow!".into(),
+ "a'b".into(),
+ ];
+ let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap();
+ let encoded = command.split_whitespace().last().unwrap();
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(encoded)
+ .unwrap();
+ let utf16 = bytes
+ .chunks_exact(2)
+ .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ String::from_utf16(&utf16).unwrap(),
+ "$p=Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b' -NoNewWindow -Wait -PassThru"
+ );
+ }
+
+ #[test]
+ fn windows_shells_round_trip_agent_arguments_through_a_real_command() {
+ let base = std::env::temp_dir().join(format!(
+ "herdr-agent-argv-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_millis()
+ ));
+ fs::create_dir_all(&base).unwrap();
+ let helper = base.join("pi.cmd");
+ fs::write(
+ &helper,
+ "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\n)\r\n",
+ )
+ .unwrap();
+ let argv = vec![
+ "pi".into(),
+ String::new(),
+ "two words".into(),
+ "100%".into(),
+ "wow!".into(),
+ "a'b".into(),
+ "@options".into(),
+ ];
+ let inherited_path = std::env::var_os("PATH").unwrap_or_default();
+ let path = format!("{};{}", base.display(), inherited_path.to_string_lossy());
+
+ for shell in ["powershell.exe", "cmd.exe"] {
+ let capture = base.join(format!("{shell}.txt"));
+ let command = super::interactive_shell_command(&argv, shell).unwrap();
+ let status = if shell == "cmd.exe" {
+ Command::new("cmd.exe")
+ .args(["/d", "/c", &command])
+ .env("PATH", &path)
+ .env("HERDR_ARGV_CAPTURE", &capture)
+ .status()
+ .unwrap()
+ } else {
+ Command::new("powershell.exe")
+ .args(["-NoLogo", "-NoProfile", "-Command", &command])
+ .env("PATH", &path)
+ .env("HERDR_ARGV_CAPTURE", &capture)
+ .status()
+ .unwrap()
+ };
+ assert!(status.success(), "{shell} command failed");
+ assert_eq!(
+ fs::read_to_string(capture).unwrap().replace("\r\n", "\n"),
+ "\ntwo words\n100%\nwow!\na'b\n@options\n"
+ );
+ }
+
+ let _ = fs::remove_dir_all(base);
+ }
+
const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE";
const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID";
@@ -1002,6 +1169,27 @@ mod tests {
}
#[test]
+ fn windows_shell_is_available_only_without_descendants() {
+ let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])];
+ assert_eq!(
+ super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(),
+ Some("powershell.exe")
+ );
+
+ let busy = vec![
+ test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
+ test_entry(20, 10, "git.exe", &["git.exe", "status"]),
+ ];
+ assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None);
+
+ let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])];
+ assert_eq!(
+ super::available_pane_shell_from_snapshot(10, &replaced),
+ None
+ );
+ }
+
+ #[test]
fn windows_process_tree_returns_shell_for_multiple_agent_descendants() {
let entries = vec![
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),