use apcore::{ErrorCode, ModuleError};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
const SHELL_INJECTION_CHARS: &[char] = &[
';', '|', '&', '$', '`', '\\', '\'', '"', '\n', '\r', '\0', '(', ')', '<', '>',
];
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
#[allow(clippy::result_large_err)] pub fn validate_no_injection(param_name: &str, value: &str) -> Result<(), ModuleError> {
let found: Vec<char> = value
.chars()
.filter(|c| SHELL_INJECTION_CHARS.contains(c))
.collect();
if !found.is_empty() {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Parameter '{}' contains prohibited characters: {:?}",
param_name, found
),
));
}
Ok(())
}
fn json_value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
}
#[allow(clippy::result_large_err)] pub fn build_arguments(
kwargs: &serde_json::Map<String, Value>,
) -> Result<Vec<String>, ModuleError> {
let mut args: Vec<String> = Vec::new();
for (key, value) in kwargs {
match value {
Value::Null => continue,
Value::Bool(b) => {
if *b {
let flag = format!("--{}", key.replace('_', "-"));
args.push(flag);
}
}
Value::Array(items) => {
for item in items {
let s = json_value_to_string(item);
validate_no_injection(key, &s)?;
args.push(format!("--{}", key.replace('_', "-")));
args.push(s);
}
}
other => {
let s = json_value_to_string(other);
validate_no_injection(key, &s)?;
args.push(format!("--{}", key.replace('_', "-")));
args.push(s);
}
}
}
Ok(args)
}
#[derive(Debug)]
pub struct SubprocessOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
fn truncate_output(bytes: &[u8], max_len: usize) -> (String, bool) {
if bytes.len() <= max_len {
return (String::from_utf8_lossy(bytes).to_string(), false);
}
let mut cut = max_len;
while cut > 0 && (bytes[cut] & 0xC0) == 0x80 {
cut -= 1;
}
(String::from_utf8_lossy(&bytes[..cut]).to_string(), true)
}
const READ_CHUNK_SIZE: usize = 8 * 1024;
async fn read_up_to<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
max_len: usize,
) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
let mut chunk = [0u8; READ_CHUNK_SIZE];
while buf.len() < max_len {
let to_read = READ_CHUNK_SIZE.min(max_len - buf.len());
let n = reader.read(&mut chunk[..to_read]).await?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
const ENV_ALLOWLIST: &[&str] = &[
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TERM", "TZ", "TMPDIR",
];
fn is_allowed_env(key: &str) -> bool {
ENV_ALLOWLIST.contains(&key) || key.starts_with("LC_")
}
#[allow(clippy::result_large_err)] pub async fn execute_subprocess(
binary_path: &str,
args: &[String],
json_flag: Option<&str>,
timeout_ms: u64,
max_output_bytes: usize,
) -> Result<SubprocessOutput, ModuleError> {
let mut full_args: Vec<String> = args.to_vec();
if let Some(flag) = json_flag {
for part in shell_words::split(flag).unwrap_or_default() {
full_args.push(part);
}
}
let timeout_duration = std::time::Duration::from_millis(timeout_ms);
let run = async {
let mut command = Command::new(binary_path);
command
.args(&full_args)
.env_clear()
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
for (key, value) in std::env::vars() {
if is_allowed_env(&key) {
command.env(key, value);
}
}
let mut child = command.spawn().map_err(|e| {
ModuleError::new(
ErrorCode::ModuleExecuteError,
format!("Failed to execute '{}': {}", binary_path, e),
)
})?;
let read_cap = max_output_bytes.saturating_add(1);
let mut stdout_pipe = child.stdout.take().expect("stdout was piped");
let mut stderr_pipe = child.stderr.take().expect("stderr was piped");
let (stdout_bytes, stderr_bytes, status) = tokio::join!(
read_up_to(&mut stdout_pipe, read_cap),
read_up_to(&mut stderr_pipe, read_cap),
child.wait(),
);
let stdout_bytes = stdout_bytes.map_err(|e| {
ModuleError::new(
ErrorCode::ModuleExecuteError,
format!("Failed to read stdout of '{binary_path}': {e}"),
)
})?;
let stderr_bytes = stderr_bytes.map_err(|e| {
ModuleError::new(
ErrorCode::ModuleExecuteError,
format!("Failed to read stderr of '{binary_path}': {e}"),
)
})?;
let status = status.map_err(|e| {
ModuleError::new(
ErrorCode::ModuleExecuteError,
format!("Failed to wait on '{binary_path}': {e}"),
)
})?;
let (stdout, stdout_truncated) = truncate_output(&stdout_bytes, max_output_bytes);
let (stderr, stderr_truncated) = truncate_output(&stderr_bytes, max_output_bytes);
Ok::<SubprocessOutput, ModuleError>(SubprocessOutput {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
stdout_truncated,
stderr_truncated,
})
};
match tokio::time::timeout(timeout_duration, run).await {
Ok(result) => result,
Err(_elapsed) => Err(ModuleError::new(
ErrorCode::ModuleTimeout,
format!("Command '{}' timed out after {}ms", binary_path, timeout_ms),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_build_arguments_string_value() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("file".to_string(), json!("test.txt"));
let args = build_arguments(&kwargs).unwrap();
assert_eq!(args, vec!["--file", "test.txt"]);
}
#[test]
fn test_build_arguments_boolean_true() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("all".to_string(), json!(true));
let args = build_arguments(&kwargs).unwrap();
assert_eq!(args, vec!["--all"]);
}
#[test]
fn test_build_arguments_boolean_false() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("all".to_string(), json!(false));
let args = build_arguments(&kwargs).unwrap();
assert!(args.is_empty());
}
#[test]
fn test_build_arguments_null_skipped() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("x".to_string(), json!(null));
let args = build_arguments(&kwargs).unwrap();
assert!(args.is_empty());
}
#[test]
fn test_build_arguments_array_values() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("include".to_string(), json!(["a", "b"]));
let args = build_arguments(&kwargs).unwrap();
assert_eq!(args, vec!["--include", "a", "--include", "b"]);
}
#[test]
fn test_build_arguments_underscore_to_hyphen() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("no_cache".to_string(), json!(true));
let args = build_arguments(&kwargs).unwrap();
assert_eq!(args, vec!["--no-cache"]);
}
#[test]
fn test_build_arguments_integer_value() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("count".to_string(), json!(5));
let args = build_arguments(&kwargs).unwrap();
assert_eq!(args, vec!["--count", "5"]);
}
#[test]
fn test_build_arguments_injection_blocked() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("msg".to_string(), json!("hi; rm"));
let result = build_arguments(&kwargs);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_build_arguments_injection_blocked_in_array() {
let mut kwargs = serde_json::Map::new();
kwargs.insert("tags".to_string(), json!(["ok", "bad; rm -rf /"]));
let result = build_arguments(&kwargs);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_validate_no_injection_clean() {
let result = validate_no_injection("file", "hello world");
assert!(result.is_ok());
}
#[test]
fn test_validate_no_injection_semicolon() {
let result = validate_no_injection("arg", "a;b");
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::GeneralInvalidInput);
}
#[test]
fn test_is_allowed_env() {
assert!(is_allowed_env("PATH"));
assert!(is_allowed_env("HOME"));
assert!(is_allowed_env("LC_CTYPE")); assert!(!is_allowed_env("AWS_SECRET_ACCESS_KEY"));
assert!(!is_allowed_env("GH_TOKEN"));
assert!(!is_allowed_env("OPENAI_API_KEY"));
assert!(!is_allowed_env("CARGO_PKG_NAME"));
}
#[tokio::test]
async fn test_execute_subprocess_scrubs_environment() {
let out = execute_subprocess(
"sh",
&["-c".to_string(), "env".to_string()],
None,
5_000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.unwrap();
assert!(
out.stdout.lines().any(|l| l.starts_with("PATH=")),
"PATH should pass through: {}",
out.stdout
);
let count = out.stdout.lines().count();
assert!(
count <= 20,
"environment was not scrubbed, child sees {count} vars: {}",
out.stdout
);
assert!(
!out.stdout.contains("CARGO_"),
"cargo/apexe env leaked to the wrapped subprocess: {}",
out.stdout
);
}
#[tokio::test]
async fn test_execute_subprocess_echo() {
let result = execute_subprocess(
"echo",
&["hello".to_string()],
None,
5000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.unwrap();
assert_eq!(result.stdout, "hello\n");
assert!(result.stderr.is_empty());
assert_eq!(result.exit_code, 0);
assert!(!result.stdout_truncated);
assert!(!result.stderr_truncated);
}
#[tokio::test]
async fn test_execute_subprocess_false() {
let result = execute_subprocess("false", &[], None, 5000, DEFAULT_MAX_OUTPUT_BYTES)
.await
.unwrap();
assert_ne!(result.exit_code, 0);
}
#[tokio::test]
async fn test_execute_subprocess_nonexistent() {
let result = execute_subprocess(
"/nonexistent_binary_that_does_not_exist",
&[],
None,
5000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ErrorCode::ModuleExecuteError);
}
#[tokio::test]
async fn test_execute_subprocess_timeout_leaves_retryable_unset() {
let result = execute_subprocess("sleep", &["1".to_string()], None, 10, 1024).await;
let err = result.unwrap_err();
assert_eq!(err.code, ErrorCode::ModuleTimeout);
assert_eq!(err.retryable, None);
}
#[tokio::test]
async fn test_execute_subprocess_truncates_large_output() {
let result = execute_subprocess(
"seq",
&["1".to_string(), "500".to_string()],
None,
5000,
64,
)
.await
.unwrap();
assert!(result.stdout.len() <= 64);
assert!(result.stdout_truncated);
}
#[tokio::test]
async fn test_execute_subprocess_kills_hung_process_on_timeout() {
let start = std::time::Instant::now();
let result = execute_subprocess("sleep", &["10".to_string()], None, 20, 1024).await;
assert!(result.is_err());
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"execute_subprocess should return promptly once the timeout elapses"
);
}
#[tokio::test]
async fn test_read_up_to_does_not_eagerly_allocate_full_cap() {
let data = b"tiny output";
let mut cursor = std::io::Cursor::new(data.to_vec());
let huge_cap = 64 * 1024 * 1024;
let result = read_up_to(&mut cursor, huge_cap).await.unwrap();
assert_eq!(result, data);
assert!(
result.capacity() < 1024 * 1024,
"capacity {} should stay proportional to the {}-byte output, not the {}-byte cap",
result.capacity(),
data.len(),
huge_cap
);
}
#[test]
fn test_truncate_output_under_limit() {
let (s, truncated) = truncate_output(b"hello", 100);
assert_eq!(s, "hello");
assert!(!truncated);
}
#[test]
fn test_truncate_output_over_limit() {
let (s, truncated) = truncate_output(b"hello world", 5);
assert_eq!(s, "hello");
assert!(truncated);
}
#[test]
fn test_truncate_output_respects_utf8_boundary() {
let bytes = "héllo".as_bytes();
let (s, truncated) = truncate_output(bytes, 2);
assert!(truncated);
assert!(s.is_char_boundary(s.len()));
assert_eq!(s, "h");
}
}