use async_stream::stream;
#[cfg(any(unix, windows))]
use portable_pty::CommandBuilder;
#[cfg(any(unix, windows))]
use portable_pty::PtySize;
#[cfg(any(unix, windows))]
use portable_pty::native_pty_system;
use regex::Regex;
use std::path::Path;
use std::pin::Pin;
use std::process::Stdio;
use std::sync::LazyLock;
#[cfg(any(unix, windows))]
use std::io::Read;
#[cfg(unix)]
use std::sync::Arc;
#[cfg(unix)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[cfg(unix)]
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tokio::io::{Error, ErrorKind};
use tokio_stream::Stream;
use tokio_stream::StreamExt;
#[cfg(any(unix, windows))]
use tokio::sync::mpsc;
#[allow(clippy::unwrap_used)]
pub fn critical_bash_patterns() -> &'static [Regex] {
static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?i)\brm\s+-[a-z]*[rRfF][a-z]*\s+\/").unwrap(),
Regex::new(r"(?i)\bsudo\s+rm\b").unwrap(),
Regex::new(r"(?i)\bchmod\s+-R\s+[0-7]+\s+\/").unwrap(),
Regex::new(r"\bchmod\s+-R\s+[ugoa+\-=rwxXst,]+\s+\/").unwrap(),
Regex::new(r"(?i)\bchown\s+-R\s+\S+\s+\/").unwrap(),
Regex::new(r"(?i):\(\)\s*\{\s*:\s*\|\s*:").unwrap(),
Regex::new(r"(?i)>\s*\/dev\/sd[a-z]").unwrap(),
Regex::new(r"(?i)\bmkfs(\.|\b)").unwrap(),
Regex::new(r"(?i)\bdd\s+if=.+of=\/dev\/").unwrap(),
Regex::new(r"(?i)\bshred\s+\/dev\/").unwrap(),
Regex::new(r"(?i)\bcryptsetup\b").unwrap(),
Regex::new(r"(?i)>\s*\/etc\/(?:passwd|shadow|sudoers)\b").unwrap(),
Regex::new(r"(?i)\btee\s+(?:-a\s+)?\/etc\/(?:passwd|shadow|sudoers)\b").unwrap(),
Regex::new(r"(?i)\b(?:curl|wget|fetch)\b[^|]*\|\s*(?:bash|sh|zsh|fish)\b").unwrap(),
Regex::new(r"(?i)(?:^|[\s;&|(])(?:bash|sh|zsh|source|\.)\s+<\(\s*(?:curl|wget|fetch)\b").unwrap(),
Regex::new(r#"(?i)\beval\s+["'`]?\$\(\s*(?:curl|wget|fetch)\b|\beval\s+`\s*(?:curl|wget|fetch)\b"#).unwrap(),
Regex::new(r"\bkill\s+-9\s+1\b").unwrap(),
Regex::new(r"(?i)(?:^|[\s;&|(])(?:shutdown|poweroff|reboot|halt)(?:\s|$|[;|&])").unwrap(),
Regex::new(r"(?i)(?:^|[\s;&|(])init\s+0\b").unwrap(),
Regex::new(r"(?i)\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s").unwrap(),
]
});
&PATTERNS
}
const BUFFER_SIZE: usize = 4096;
#[cfg(unix)]
const WATCHDOG_TICK_MS: libc::c_int = 250;
#[cfg(unix)]
const WATCHDOG_RAW_MODE_GRACE: Duration = Duration::from_secs(2);
#[cfg(unix)]
const WATCHDOG_MESSAGE: &str = "input-wait watchdog: killed a process that entered raw (interactive) \
terminal mode and waited for keyboard input the harness cannot provide \
(interactive pager/editor/TUI).";
static ENV_VAR_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap());
#[allow(clippy::unwrap_used)]
fn env_var_pattern() -> &'static Regex {
&ENV_VAR_PATTERN
}
#[cfg(windows)]
fn resolve_bash() -> &'static str {
static RESOLVED: LazyLock<String> = LazyLock::new(|| {
let is_real_bash = |p: &Path| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("bash.exe"))
&& !p.components().any(|c| {
c.as_os_str().to_str().is_some_and(|s| {
s.eq_ignore_ascii_case("WindowsApps") || s.eq_ignore_ascii_case("System32")
})
})
};
if let Ok(output) = std::process::Command::new("where.exe")
.arg("bash.exe")
.stdin(Stdio::null())
.output()
&& output.status.success()
{
for line in String::from_utf8_lossy(&output.stdout).lines() {
let path = Path::new(line.trim());
if is_real_bash(path) {
return path.to_string_lossy().into_owned();
}
}
}
for candidate in [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
r"C:\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
r"C:\Git\usr\bin\bash.exe",
] {
let path = Path::new(candidate);
if path.is_file() {
return candidate.to_string();
}
}
panic!(
"bash.exe not found: install Git for Windows (https://git-scm.com/download/win) \
or ensure a Git Bash `bash.exe` is on PATH"
);
});
RESOLVED.as_str()
}
#[cfg(not(windows))]
fn resolve_bash() -> &'static str {
"bash"
}
#[cfg(unix)]
fn signal_name_to_number(name: &str) -> Option<i32> {
match name {
"Hangup" => Some(1),
"Interrupt" => Some(2),
"Quit" => Some(3),
"Illegal instruction" => Some(4),
"Trace/breakpoint trap" => Some(5),
"Aborted" => Some(6),
"Bus error" => Some(7),
"Arithmetic exception" | "Floating point exception" => Some(8),
"Killed" => Some(9),
"User defined signal 1" => Some(10),
"Segmentation fault" => Some(11),
"User defined signal 2" => Some(12),
"Broken pipe" => Some(13),
"Alarm clock" => Some(14),
"Terminated" => Some(15),
"Stack fault" => Some(16),
"Child exited" => Some(17),
"Continued" => Some(18),
"Stopped (signal)" => Some(19),
"Stopped" => Some(20),
"Stopped (tty input)" => Some(21),
"Stopped (tty output)" => Some(22),
"Urgent I/O condition" => Some(23),
"CPU time limit exceeded" => Some(24),
"File size limit exceeded" => Some(25),
"Virtual timer expired" => Some(26),
"Profiling timer expired" => Some(27),
"Window changed" => Some(28),
"I/O possible" => Some(29),
"Power failure" => Some(30),
"Bad system call" => Some(31),
_ => None,
}
}
pub struct BashOutput {
pub stdout: Option<String>,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
}
pub struct SpawnOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub truncated: bool,
}
pub struct ExecError {
pub stderr: Option<String>,
pub signal: Option<i32>,
}
pub struct BashError {
pub text_err: Option<String>,
pub exec_err: Option<ExecError>,
}
pub(super) fn validate_bash_patterns(command: &str) -> Result<(), String> {
let patterns = critical_bash_patterns();
for pattern in patterns {
if pattern.is_match(command) {
return Err(format!("Pattern match found: {}", pattern.as_str()));
}
}
Ok(())
}
pub fn run(
timeout_ms: Option<u64>,
env: &Option<Vec<(String, String)>>,
pty: bool,
command: &str,
cwd: &str,
) -> Result<Pin<Box<dyn Stream<Item = SpawnOutput> + Send>>, BashError> {
let is_absolute = Path::new(command).is_absolute() || command.starts_with('/');
if is_absolute {
return Err(BashError {
text_err: Some("absolute command not allowed, use relative path".to_string()),
exec_err: None,
});
}
if let Err(err) = validate_bash_patterns(command) {
return Err(BashError {
text_err: Some(err),
exec_err: None,
});
}
#[cfg(any(unix, windows))]
let (fixed_command, autofix_note) = auto_fix_command(command);
#[cfg(any(unix, windows))]
if fixed_command != command
&& let Err(err) = validate_bash_patterns(&fixed_command)
{
return Err(BashError {
text_err: Some(err),
exec_err: None,
});
}
#[cfg(any(unix, windows))]
let command: String = fixed_command;
#[cfg(not(any(unix, windows)))]
let command: String = command.to_string();
#[cfg(not(any(unix, windows)))]
let autofix_note: Option<String> = None;
let env = (*env).clone();
let cwd = cwd.to_string();
let use_pty = pty;
Ok(Box::pin(stream! {
if let Some(note) = autofix_note {
yield SpawnOutput {
stdout: vec![],
stderr: note.into_bytes(),
exit_code: None,
signal: None,
truncated: false,
};
}
let mut stream: Pin<Box<dyn Stream<Item = Result<SpawnOutput, Error>> + Send>> = if use_pty {
#[cfg(any(unix, windows))]
{
spawn_bash_pty(env, &cwd, &command, timeout_ms)
}
#[cfg(not(any(unix, windows)))]
{
spawn_bash(env, cwd.as_str(), command.as_str(), timeout_ms)
}
} else {
spawn_bash(env, cwd.as_str(), command.as_str(), timeout_ms)
};
while let Some(item) = stream.next().await {
match item {
Ok(output) => yield output,
Err(e) => {
yield SpawnOutput {
stdout: vec![],
stderr: format!("bash stream error: {e}").into_bytes(),
exit_code: None,
signal: None,
truncated: false,
};
}
}
}
}))
}
const NON_INTERACTIVE_DEFAULTS: &[(&str, &str)] = &[
("PAGER", "cat"),
("GIT_PAGER", "cat"),
("MANPAGER", "cat"),
("GIT_EDITOR", "true"),
("EDITOR", "true"),
("VISUAL", "true"),
];
fn non_interactive_defaults(
caller_env: &Option<Vec<(String, String)>>,
) -> Vec<(&'static str, &'static str)> {
let caller_keys: Vec<&str> = caller_env
.as_ref()
.map(|pairs| pairs.iter().map(|(k, _)| k.as_str()).collect())
.unwrap_or_default();
NON_INTERACTIVE_DEFAULTS
.iter()
.copied()
.filter(|(key, _)| !caller_keys.contains(key))
.collect()
}
#[cfg(any(unix, windows))]
static AUTO_FIX_GIT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"^(?:\s*[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(git)",
r"(?:\s+(?:",
r"-[cC]\s+\S+", r"|--(?:git-dir|work-tree|namespace|super-prefix)\s+\S+", r"|-{1,2}[\w-]+\S*", r"))*",
r"(?:\s+(log|diff|show|blame|shortlog|reflog|whatchanged)\b)",
))
.unwrap()
});
#[cfg(any(unix, windows))]
static AUTO_FIX_PIPE_PAGER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\|\s*(less|more|most)\b(?:\s+-{1,2}[\w][\w-]*)*\s*$").unwrap());
#[cfg(any(unix, windows))]
static AUTO_FIX_PAGER_CMD_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(less|more)\s+([^;|&]+)$").unwrap());
#[cfg(any(unix, windows))]
pub(crate) fn auto_fix_command(command: &str) -> (String, Option<String>) {
use regex::Captures;
fn apply_one(command: &str) -> Option<(String, String)> {
if command.contains("--no-pager") {
return None;
}
if AUTO_FIX_PIPE_PAGER_RE.is_match(command) {
let fixed = AUTO_FIX_PIPE_PAGER_RE.replace(command, |_: &Captures| "| cat");
return Some((
fixed.into_owned(),
"auto-fix: replaced the trailing pager in the pipeline with `cat` to keep the output complete".to_string(),
));
}
if !command.contains('|')
&& !command.contains(';')
&& !command.contains('&')
&& let Some(cap) = AUTO_FIX_GIT_RE.captures(command)
{
let git_token = cap.get(1)?;
let sub = cap.get(2)?.as_str();
let insert_at = git_token.end();
let mut fixed = String::with_capacity(command.len() + "--no-pager ".len());
fixed.push_str(&command[..insert_at]);
fixed.push_str(" --no-pager");
fixed.push_str(&command[insert_at..]);
return Some((
fixed,
format!(
"auto-fix: inserted `--no-pager` into `git {sub}` to prevent an interactive pager"
),
));
}
if let Some(cap) = AUTO_FIX_PAGER_CMD_RE.captures(command) {
let args = cap.get(2)?.as_str();
if args.split_whitespace().all(|tok| !tok.starts_with('-')) {
let pager = cap.get(1)?.as_str();
return Some((
format!("cat {args}"),
format!(
"auto-fix: replaced `{pager}` with `cat` to print the file(s) directly"
),
));
}
}
None
}
if command.contains('"') || command.contains('\'') || command.contains('`') {
return (command.to_string(), None);
}
if command.contains('\n') {
return (command.to_string(), None);
}
if command.contains("--paginate") {
return (command.to_string(), None);
}
let mut current = command.to_string();
let mut notes: Vec<String> = Vec::new();
for _ in 0..4 {
match apply_one(¤t) {
Some((fixed, note)) => {
notes.push(note);
current = fixed;
}
None => break,
}
}
let note = (!notes.is_empty()).then(|| notes.join("; "));
(current, note)
}
pub(crate) fn spawn_bash(
env: Option<Vec<(String, String)>>,
cwd: impl Into<String>,
command: impl Into<String>,
timeout_ms: Option<u64>,
) -> Pin<Box<dyn Stream<Item = Result<SpawnOutput, Error>> + Send>> {
let cwd = cwd.into();
let command = command.into();
let mut buffer_stdout = [0u8; BUFFER_SIZE];
let mut buffer_stderr = [0u8; BUFFER_SIZE];
let mut cmd = tokio::process::Command::new(resolve_bash());
Box::pin(stream! {
if let Some(ref env) = env {
let valid_pattern = env_var_pattern();
for (key, value) in env {
if !valid_pattern.is_match(key) {
yield Err(Error::new(
ErrorKind::InvalidInput,
format!("invalid env variable name: {key}"),
));
return;
}
cmd.env(key, value);
}
}
for (key, value) in non_interactive_defaults(&env) {
cmd.env(key, value);
}
if !cwd.is_empty() {
cmd.current_dir(cwd);
}
cmd.arg("-c").arg(command);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
yield Err(Error::other(format!("failed to spawn bash process: {e}")));
return;
}
};
#[allow(clippy::expect_used)]
let mut stream_stdout = child.stdout.take().expect("stdout pipe should be configured");
#[allow(clippy::expect_used)]
let mut stream_stderr = child.stderr.take().expect("stderr pipe should be configured");
let mut stdout_done = false;
let mut stderr_done = false;
if timeout_ms == Some(0) {
let _ = child.kill().await;
let _ = child.wait().await;
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
let deadline = timeout_ms.map(|ms| tokio::time::Instant::now() + Duration::from_millis(ms));
loop {
tokio::select! {
biased;
() = async {
match deadline {
Some(dl) => tokio::time::sleep_until(dl).await,
None => std::future::pending::<()>().await,
}
} => {
let _ = child.kill().await;
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
result_stdout = stream_stdout.read(&mut buffer_stdout), if !stdout_done => {
match result_stdout {
Ok(0) => stdout_done = true,
Ok(n) => {
let stdout = buffer_stdout[..n].to_vec();
yield Ok(SpawnOutput {
stdout,
stderr: vec![],
exit_code: None,
signal: None,
truncated: n == BUFFER_SIZE,
});
}
Err(e) => {
yield Err(Error::other(format!("stdout read error: {e}")));
stdout_done = true;
}
}
}
result_stderr = stream_stderr.read(&mut buffer_stderr), if !stderr_done => {
match result_stderr {
Ok(0) => stderr_done = true,
Ok(n) => {
let stderr = buffer_stderr[..n].to_vec();
yield Ok(SpawnOutput {
stdout: vec![],
stderr,
exit_code: None,
signal: None,
truncated: n == BUFFER_SIZE,
});
}
Err(e) => {
yield Err(Error::other(format!("stderr read error: {e}")));
stderr_done = true;
}
}
}
}
if stdout_done && stderr_done { break; }
}
let status = match child.wait().await {
Ok(s) => s,
Err(e) => {
yield Err(Error::other(format!("failed to wait for child: {e}")));
return;
}
};
let signal = {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status.signal()
}
#[cfg(not(unix))]
{
None
}
};
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: status.code(),
signal,
truncated: false,
});
})
}
#[cfg(unix)]
pub(crate) fn spawn_bash_pty(
env: Option<Vec<(String, String)>>,
cwd: &str,
command: &str,
timeout_ms: Option<u64>,
) -> Pin<Box<dyn Stream<Item = Result<SpawnOutput, Error>> + Send>> {
let cwd = cwd.to_string();
let command = command.to_string();
let kill_flag = Arc::new(AtomicBool::new(false));
Box::pin(stream! {
if timeout_ms == Some(0) {
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
let (tx, mut rx) = mpsc::unbounded_channel();
let kill = kill_flag.clone();
let mut pipe_fds: [libc::c_int; 2] = [0; 2];
let pipe_result = unsafe {
#[cfg(not(target_os = "macos"))]
{
libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC)
}
#[cfg(target_os = "macos")]
{
let rc = libc::pipe(pipe_fds.as_mut_ptr());
if rc == 0 {
for fd in pipe_fds {
libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC);
}
}
rc
}
};
if pipe_result != 0_i32 {
yield Err(Error::other("failed to create self-pipe for timeout"));
return;
}
let [pipe_rx, pipe_tx] = pipe_fds;
tokio::task::spawn_blocking(move || {
if let Some(ref env) = env {
let valid_pattern = env_var_pattern();
for (key, _) in env {
if !valid_pattern.is_match(key) {
let _ = tx.send(Err(Error::new(
ErrorKind::InvalidInput,
format!("invalid env variable name: {key}"),
)));
return;
}
}
}
let pty_system = native_pty_system();
let pair = match pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}) {
Ok(p) => p,
Err(e) => {
let _ = tx.send(Err(Error::other(format!("failed to open pty: {e}"))));
return;
}
};
let mut cmd_builder = CommandBuilder::new("bash");
cmd_builder.arg("-c");
cmd_builder.arg(&command);
if !cwd.is_empty() {
cmd_builder.cwd(&cwd);
}
let non_interactive = non_interactive_defaults(&env);
if let Some(env) = env {
for (key, value) in env {
cmd_builder.env(key, value);
}
}
for (key, value) in non_interactive {
cmd_builder.env(key, value);
}
let mut child = match pair.slave.spawn_command(cmd_builder) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(Err(Error::other(
format!("failed to spawn command in pty: {e}"),
)));
return;
}
};
let mut reader = match pair.master.try_clone_reader() {
Ok(r) => r,
Err(e) => {
let _ = tx.send(Err(Error::other(
format!("failed to clone pty reader: {e}"),
)));
return;
}
};
drop(pair.slave);
let Some(pty_fd) = pair.master.as_raw_fd() else {
let _ = tx.send(Err(Error::other("failed to get PTY fd")));
return;
};
let mut poll_fds = [
libc::pollfd { fd: pty_fd, events: libc::POLLIN, revents: 0 },
libc::pollfd { fd: pipe_rx, events: libc::POLLIN, revents: 0 },
];
let mut buf = [0u8; BUFFER_SIZE];
let mut watchdog_raw_since: Option<Instant> = None;
loop {
let _n_ready = loop {
let res = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, WATCHDOG_TICK_MS) };
if res < 0_i32 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EINTR) {
continue;
}
let _ = tx.send(Err(Error::other(format!("pty poll error: {err}"))));
return;
}
break res;
};
if poll_fds[1].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 {
let _ = unsafe {
libc::read(
pipe_rx,
buf.as_mut_ptr().cast::<libc::c_void>(),
buf.len(),
)
};
break;
}
if poll_fds[0].revents & libc::POLLIN != 0 {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx
.send(Ok(SpawnOutput {
stdout: buf[..n].to_vec(),
stderr: vec![],
exit_code: None,
signal: None,
truncated: n == BUFFER_SIZE,
}))
.is_err()
{
break;
}
}
Err(e) => {
let _ = tx.send(Err(Error::other(format!("pty read error: {e}"))));
return;
}
}
}
if poll_fds[0].revents & (libc::POLLHUP | libc::POLLERR) != 0 {
match reader.read(&mut buf) {
Ok(0) | Err(_) => {}
Ok(n) => {
let _ = tx.send(Ok(SpawnOutput {
stdout: buf[..n].to_vec(),
stderr: vec![],
exit_code: None,
signal: None,
truncated: n == BUFFER_SIZE,
}));
}
}
break;
}
let child_alive = matches!(child.try_wait(), Ok(None));
if !child_alive {
watchdog_raw_since = None;
} else {
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
let is_raw = unsafe { libc::tcgetattr(pty_fd, &mut termios) } == 0
&& termios.c_lflag & libc::ICANON == 0;
if !is_raw {
watchdog_raw_since = None;
} else {
let since =
*watchdog_raw_since.get_or_insert_with(Instant::now);
if since.elapsed() >= WATCHDOG_RAW_MODE_GRACE {
let _ = child.kill();
let _ = tx.send(Ok(SpawnOutput {
stdout: vec![],
stderr: WATCHDOG_MESSAGE.as_bytes().to_vec(),
exit_code: None,
signal: None,
truncated: false,
}));
break;
}
}
}
}
unsafe { libc::close(pipe_rx); }
if kill.load(Ordering::Relaxed) {
let _ = child.kill();
}
match child.wait() {
Ok(status) => {
let signal = status.signal().and_then(signal_name_to_number);
let exit_code = if signal.is_some() {
None
} else {
#[allow(clippy::cast_possible_wrap)]
Some(status.exit_code() as i32)
};
let _ = tx.send(Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code,
signal,
truncated: false,
}));
}
Err(e) => {
let _ = tx.send(Err(Error::other(format!("pty wait error: {e}"))));
}
}
});
let deadline = timeout_ms.map(|ms| tokio::time::Instant::now() + Duration::from_millis(ms));
loop {
tokio::select! {
biased;
() = async {
match deadline {
Some(dl) => tokio::time::sleep_until(dl).await,
None => std::future::pending::<()>().await,
}
} => {
kill_flag.store(true, Ordering::Relaxed);
let byte: u8 = 0;
unsafe {
libc::write(
pipe_tx,
(&raw const byte).cast::<libc::c_void>(),
1,
);
}
unsafe { libc::close(pipe_tx); }
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
item = rx.recv() => {
match item {
Some(result) => yield result,
None => break,
}
}
}
}
unsafe { libc::close(pipe_tx); }
})
}
#[cfg(windows)]
use portable_pty::ChildKiller;
#[cfg(windows)]
pub(crate) fn spawn_bash_pty(
env: Option<Vec<(String, String)>>,
cwd: &str,
command: &str,
timeout_ms: Option<u64>,
) -> Pin<Box<dyn Stream<Item = Result<SpawnOutput, Error>> + Send>> {
let cwd = cwd.to_string();
let command = command.to_string();
Box::pin(stream! {
if timeout_ms == Some(0) {
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
if let Some(env) = &env {
let valid_pattern = env_var_pattern();
for (key, _) in env {
if !valid_pattern.is_match(key) {
yield Err(Error::new(
ErrorKind::InvalidInput,
format!("invalid env variable name: {key}"),
));
return;
}
}
}
let pty_system = native_pty_system();
let pair = match pty_system.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
}) {
Ok(p) => p,
Err(e) => {
yield Err(Error::other(format!("failed to open pty: {e}")));
return;
}
};
let mut cmd_builder = CommandBuilder::new(resolve_bash());
cmd_builder.arg("-c");
cmd_builder.arg(&command);
if !cwd.is_empty() {
cmd_builder.cwd(&cwd);
}
if let Some(env) = env {
for (key, value) in env {
cmd_builder.env(key, value);
}
}
let mut child = match pair.slave.spawn_command(cmd_builder) {
Ok(c) => c,
Err(e) => {
yield Err(Error::other(format!("failed to spawn command in pty: {e}")));
return;
}
};
let mut reader = match pair.master.try_clone_reader() {
Ok(r) => r,
Err(e) => {
yield Err(Error::other(format!("failed to clone pty reader: {e}")));
return;
}
};
let mut writer = match pair.master.take_writer() {
Ok(w) => w,
Err(e) => {
yield Err(Error::other(format!("failed to take pty writer: {e}")));
return;
}
};
let mut killer = ChildKiller::clone_killer(&*child);
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; BUFFER_SIZE];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
}
}
let _ = tx.send(Vec::new());
});
let (stx, mut srx) = mpsc::unbounded_channel::<(Option<i32>, Option<i32>)>();
std::thread::spawn(move || match child.wait() {
Ok(status) => {
#[allow(clippy::cast_possible_wrap)]
let _ = stx.send((Some(status.exit_code() as i32), None));
}
Err(e) => {
let _ = stx.send((None, None));
let _ = e;
}
});
const DSR_QUERY: &[u8] = b"\x1b[6n";
const DSR_REPLY: &[u8] = b"\x1b[1;1R";
const DRAIN_WINDOW: Duration = Duration::from_millis(300);
let deadline =
timeout_ms.map(|ms| tokio::time::Instant::now() + Duration::from_millis(ms));
let mut timed_out = false;
let mut answered_dsr = false;
let mut status: Option<(Option<i32>, Option<i32>)> = None;
let mut drained: Vec<Vec<u8>> = Vec::new();
loop {
tokio::select! {
biased;
() = async {
match deadline {
Some(dl) => tokio::time::sleep_until(dl).await,
None => std::future::pending::<()>().await,
}
}, if deadline.is_some() && !timed_out => {
timed_out = true;
killer.kill().ok();
break;
}
status_msg = srx.recv() => {
match status_msg {
Some(s) => {
status = Some(s);
let _ = tokio::time::timeout(DRAIN_WINDOW, async {
while let Some(chunk) = rx.recv().await {
if chunk.is_empty() {
break;
}
drained.push(chunk);
}
})
.await;
break;
}
None => break,
}
}
chunk = rx.recv() => {
match chunk {
Some(data) => {
if data.is_empty() {
continue;
}
if !answered_dsr
&& data.windows(DSR_QUERY.len()).any(|w| w == DSR_QUERY)
{
answered_dsr = true;
let _ = writer.write_all(DSR_REPLY);
let _ = writer.flush();
}
yield Ok(SpawnOutput {
stdout: data,
stderr: vec![],
exit_code: None,
signal: None,
truncated: false,
});
}
None => break,
}
}
}
}
for chunk in drained {
yield Ok(SpawnOutput {
stdout: chunk,
stderr: vec![],
exit_code: None,
signal: None,
truncated: false,
});
}
if timed_out {
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code: None,
signal: Some(-1_i32),
truncated: false,
});
return;
}
if let Some((exit_code, signal)) = status {
yield Ok(SpawnOutput {
stdout: vec![],
stderr: vec![],
exit_code,
signal,
truncated: false,
});
}
})
}