use super::super::generated::yosh::plugin::commands::ExecOutput;
use super::super::generated::yosh::plugin::types::ErrorCode;
use super::HostContext;
pub fn host_commands_exec(
ctx: &HostContext,
program: &str,
args: &[std::borrow::Cow<'_, str>],
) -> Result<ExecOutput, ErrorCode> {
ctx.ensure_bound()?;
if program.is_empty() {
return Err(ErrorCode::InvalidArgument);
}
let argv: Vec<&str> = std::iter::once(program)
.chain(args.iter().map(|c| c.as_ref()))
.collect();
if !ctx.allowed_commands.iter().any(|p| p.matches(&argv)) {
return Err(ErrorCode::PatternNotAllowed);
}
spawn_with_timeout(program, &argv[1..], std::time::Duration::from_millis(1000))
}
pub fn deny_commands_exec() -> Result<ExecOutput, ErrorCode> {
Err(ErrorCode::Denied)
}
fn spawn_with_timeout(
program: &str,
args: &[&str],
timeout: std::time::Duration,
) -> Result<ExecOutput, ErrorCode> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Instant;
let mut child = match Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(ErrorCode::NotFound),
Err(_) => return Err(ErrorCode::IoFailed),
};
let mut stdout_pipe = child.stdout.take().expect("piped stdout");
let mut stderr_pipe = child.stderr.take().expect("piped stderr");
let (out_tx, out_rx) = mpsc::channel::<std::io::Result<Vec<u8>>>();
let (err_tx, err_rx) = mpsc::channel::<std::io::Result<Vec<u8>>>();
thread::spawn(move || {
let mut buf = Vec::new();
let r = stdout_pipe.read_to_end(&mut buf).map(|_| buf);
let _ = out_tx.send(r);
});
thread::spawn(move || {
let mut buf = Vec::new();
let r = stderr_pipe.read_to_end(&mut buf).map(|_| buf);
let _ = err_tx.send(r);
});
let deadline = Instant::now() + timeout;
let exit_status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(_) => return Err(ErrorCode::IoFailed),
}
if Instant::now() >= deadline {
let pid = nix::unistd::Pid::from_raw(child.id() as i32);
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
let grace = Instant::now() + std::time::Duration::from_millis(100);
loop {
if let Ok(Some(_)) = child.try_wait() {
break;
}
if Instant::now() >= grace {
let _ = child.kill();
let _ = child.wait();
break;
}
thread::sleep(std::time::Duration::from_millis(10));
}
let _ = out_rx.recv();
let _ = err_rx.recv();
return Err(ErrorCode::Timeout);
}
thread::sleep(std::time::Duration::from_millis(10));
};
let stdout = out_rx.recv().ok().and_then(|r| r.ok()).unwrap_or_default();
let stderr = err_rx.recv().ok().and_then(|r| r.ok()).unwrap_or_default();
Ok(ExecOutput {
exit_code: exit_status.code().unwrap_or(-1),
stdout,
stderr,
})
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::{bound_env_ctx, ctx_with_allowed, null_env_ctx};
use super::*;
use crate::env::ShellEnv;
use std::borrow::Cow;
#[test]
fn commands_exec_denied_when_env_null() {
let ctx = null_env_ctx();
let result = host_commands_exec(&ctx, "/bin/echo", &[Cow::Borrowed("hi")]);
assert!(matches!(result, Err(ErrorCode::Denied)));
}
#[test]
fn host_commands_exec_invalid_argument_on_empty_program() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = bound_env_ctx(&mut env);
let result = host_commands_exec(&ctx, "", &[]);
assert!(matches!(result, Err(ErrorCode::InvalidArgument)));
}
#[test]
fn host_commands_exec_pattern_not_allowed_when_no_match() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["ls:*"]);
let result = host_commands_exec(&ctx, "echo", &[Cow::Borrowed("hi")]);
assert!(matches!(result, Err(ErrorCode::PatternNotAllowed)));
}
#[test]
fn host_commands_exec_runs_when_pattern_matches() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/bin/echo:*"]);
let result = host_commands_exec(&ctx, "/bin/echo", &[Cow::Borrowed("hello")])
.expect("echo should succeed");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, b"hello\n");
assert!(result.stderr.is_empty());
}
#[test]
fn host_commands_exec_captures_stderr_separately() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/bin/sh:*"]);
let result = host_commands_exec(
&ctx,
"/bin/sh",
&[
Cow::Borrowed("-c"),
Cow::Borrowed("echo out; echo err 1>&2"),
],
)
.expect("sh should succeed");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, b"out\n");
assert!(
result.stderr.ends_with(b"err\n"),
"stderr should end with the captured `err\\n` line, got {:?}",
String::from_utf8_lossy(&result.stderr),
);
}
#[test]
fn host_commands_exec_propagates_nonzero_exit() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/bin/sh:*"]);
let result = host_commands_exec(
&ctx,
"/bin/sh",
&[Cow::Borrowed("-c"), Cow::Borrowed("exit 42")],
)
.expect("sh should run to exit");
assert_eq!(result.exit_code, 42);
}
#[test]
fn host_commands_exec_returns_not_found_for_missing_binary() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/no/such/binary-xyz:*"]);
let result = host_commands_exec(&ctx, "/no/such/binary-xyz", &[]);
assert!(matches!(result, Err(ErrorCode::NotFound)));
}
#[test]
fn host_commands_exec_timeout_after_1000ms() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/bin/sleep:*"]);
let start = std::time::Instant::now();
let result = host_commands_exec(&ctx, "/bin/sleep", &[Cow::Borrowed("5")]);
let elapsed = start.elapsed();
assert!(matches!(result, Err(ErrorCode::Timeout)));
assert!(
elapsed < std::time::Duration::from_millis(2000),
"timeout took {:?}, expected <2000ms",
elapsed
);
}
#[test]
fn host_commands_exec_kills_child_on_timeout() {
let mut env = ShellEnv::new("yosh", vec![]);
let ctx = ctx_with_allowed(&mut env, &["/bin/sleep:*"]);
let start = std::time::Instant::now();
let result = host_commands_exec(&ctx, "/bin/sleep", &[Cow::Borrowed("5")]);
let elapsed = start.elapsed();
assert!(matches!(result, Err(ErrorCode::Timeout)));
assert!(
elapsed >= std::time::Duration::from_millis(900),
"elapsed {:?} too small — timeout fired before deadline",
elapsed
);
assert!(
elapsed < std::time::Duration::from_millis(2000),
"elapsed {:?} too large — child may not have been reaped",
elapsed
);
}
}