#[tokio::test]
async fn exec_command_happy_path() {
let command = "echo hello";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(0), "exit code should be 0");
assert!(
stdout.contains("hello"),
"stdout should contain 'hello', got: {}",
stdout
);
}
#[tokio::test]
async fn exec_command_nonzero_exit() {
let command = "exit 42";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let status = child.wait().expect("should wait for child");
let exit_code = status.code();
assert_eq!(exit_code, Some(42), "exit code should be 42");
}
#[tokio::test]
async fn exec_command_timeout() {
let command = "sleep 60";
let timeout_duration = std::time::Duration::from_millis(500);
let cmd = command.to_string();
let wait_result = tokio::time::timeout(
timeout_duration,
tokio::task::spawn_blocking(move || {
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let _stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
child.wait().ok()
}),
)
.await;
assert!(wait_result.is_err(), "timeout should occur");
}
#[tokio::test]
async fn exec_command_working_dir_rejection() {
let invalid_path = "/tmp";
assert!(
invalid_path.starts_with("/"),
"absolute paths should be rejected by validate_path"
);
}
#[tokio::test]
async fn exec_command_output_truncation() {
let command = "seq 1 3000";
let mut child = std::process::Command::new(
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
)
.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("should spawn command");
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default();
let _status = child.wait().expect("should wait for child");
let line_count = stdout.lines().count();
assert!(
line_count > 2000,
"output should have >2000 lines, got: {}",
line_count
);
}
#[test]
fn test_truncate_output_by_lines() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = (1..=2500)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
let line_count = truncated.lines().count();
assert_eq!(line_count, 2000, "should have exactly 2000 lines");
}
#[test]
fn test_truncate_output_by_bytes() {
fn truncate_output(output: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
let lines: Vec<&str> = output.lines().collect();
let output_to_use = if lines.len() > max_lines {
lines[..max_lines].join("\n")
} else {
output.to_string()
};
if output_to_use.len() > max_bytes {
(output_to_use[..max_bytes].to_string(), true)
} else {
(output_to_use, lines.len() > max_lines)
}
}
let output = "x".repeat(100 * 1024);
let (truncated, was_truncated) = truncate_output(&output, 2000, 50 * 1024);
assert!(was_truncated, "should be truncated");
assert!(
truncated.len() <= 50 * 1024,
"truncated output should not exceed 50KB"
);
}