use std::collections::BTreeMap;
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
const MAX_FRAME_BYTES: u64 = 64 * 1024;
pub const AGENT_PORT: u16 = 8971;
pub const FRAME_CHANNEL_DEPTH: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessFrame {
Output {
seq: u64,
stream: ProcessStream,
data: Vec<u8>,
},
Exit {
code: i32,
truncated: bool,
},
Failed { code: &'static str, message: String },
}
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
pub fn spawn(program: &str, arguments: &[String]) -> std::io::Result<Command> {
let mut command = Command::new(program);
command
.args(arguments)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
command.process_group(0);
Ok(command)
}
async fn kill_all(child: &mut Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
let _ = child.kill().await;
}
pub fn spawn_sandboxed(
program: &str,
arguments: &[String],
environment: &BTreeMap<String, String>,
) -> std::io::Result<Command> {
let mut command = spawn(program, arguments)?;
command
.env_clear()
.env("PATH", DEFAULT_PATH)
.envs(environment);
Ok(command)
}
pub async fn stream(
mut child: Child,
timeout: Duration,
output_cap: usize,
frames: mpsc::Sender<ProcessFrame>,
) {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let seq = AtomicU64::new(0);
let truncated = AtomicBool::new(false);
let read_error: Mutex<Option<String>> = Mutex::new(None);
let pump = async {
let (stdout_connected, stderr_connected) = tokio::join!(
drain(
stdout,
ProcessStream::Stdout,
output_cap,
&seq,
&truncated,
&read_error,
&frames
),
drain(
stderr,
ProcessStream::Stderr,
output_cap,
&seq,
&truncated,
&read_error,
&frames
),
);
stdout_connected && stderr_connected
};
let mut connected = true;
let outcome = tokio::time::timeout(timeout, async {
tokio::select! {
biased;
() = frames.closed() => {
connected = false;
Ok(std::process::ExitStatus::default())
}
result = async {
connected = pump.await;
child.wait().await
} => result,
}
})
.await;
if !connected {
kill_all(&mut child).await;
return;
}
let truncated = truncated.load(Ordering::Relaxed);
let read_error = read_error.into_inner().expect("no panic holds this lock");
let terminal = match outcome {
Ok(Ok(_)) if read_error.is_some() => ProcessFrame::Failed {
code: "outputReadFailed",
message: read_error.expect("checked"),
},
Ok(Ok(status)) => ProcessFrame::Exit {
code: status.code().unwrap_or(-1),
truncated,
},
Ok(Err(error)) => ProcessFrame::Failed {
code: "waitFailed",
message: error.to_string(),
},
Err(_) => {
kill_all(&mut child).await;
ProcessFrame::Failed {
code: "timeoutExceeded",
message: format!("exceeded its {}ms timeout", timeout.as_millis()),
}
}
};
let _ = frames.send(terminal).await;
}
pub async fn run(child: Child, timeout: Duration, output_cap: usize) -> Vec<ProcessFrame> {
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = stream(child, timeout, output_cap, sender);
let consume = async {
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
let (_, frames) = tokio::join!(produce, consume);
frames
}
async fn drain<R>(
stream: Option<R>,
which: ProcessStream,
output_cap: usize,
seq: &AtomicU64,
truncated: &AtomicBool,
read_error: &Mutex<Option<String>>,
frames: &mpsc::Sender<ProcessFrame>,
) -> bool
where
R: tokio::io::AsyncRead + Unpin,
{
let Some(stream) = stream else {
return true;
};
let mut reader = BufReader::new(stream);
let mut kept = 0usize;
loop {
let mut line = Vec::new();
match (&mut reader)
.take(MAX_FRAME_BYTES)
.read_until(b'\n', &mut line)
.await
{
Ok(0) => return true,
Err(error) => {
read_error
.lock()
.expect("no panic holds this lock")
.get_or_insert_with(|| error.to_string());
return true;
}
Ok(read) => {
let number = seq.fetch_add(1, Ordering::Relaxed);
if kept + read > output_cap {
truncated.store(true, Ordering::Relaxed);
continue;
}
kept += read;
if frames
.send(ProcessFrame::Output {
seq: number,
stream: which,
data: line,
})
.await
.is_err()
{
return false;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn child(command: &[&str]) -> Child {
let arguments: Vec<String> = command[1..].iter().map(|s| s.to_string()).collect();
spawn(command[0], &arguments)
.expect("command builds")
.spawn()
.expect("command spawns")
}
#[tokio::test]
async fn a_silent_command_is_killed_when_the_caller_stops_listening() {
let (sender, receiver) = mpsc::channel(4);
let running = tokio::spawn(stream(
child(&["/bin/sh", "-c", "sleep 60"]),
Duration::from_secs(60),
1024,
sender,
));
drop(receiver);
tokio::time::timeout(Duration::from_secs(10), running)
.await
.expect("dropping the receiver stops the command rather than waiting out its deadline")
.expect("the task does not panic");
}
fn terminal(frames: &[ProcessFrame]) -> &ProcessFrame {
frames.last().expect("there is always a terminal frame")
}
fn stdout_of(frames: &[ProcessFrame]) -> String {
let mut collected = Vec::new();
for frame in frames {
if let ProcessFrame::Output {
stream: ProcessStream::Stdout,
data,
..
} = frame
{
collected.extend_from_slice(data);
}
}
String::from_utf8_lossy(&collected).into_owned()
}
#[tokio::test]
#[cfg(unix)]
async fn a_deadline_kills_what_the_command_forked() {
let marker = std::env::temp_dir().join(format!("alien-forked-{}", std::process::id()));
let _ = std::fs::remove_file(&marker);
let script = format!(
"(echo x >> {m}; while true; do echo x >> {m} ; sleep 0.05; done) >/dev/null 2>&1 &\nsleep 30",
m = marker.display()
);
let command = spawn("/bin/sh", &["-c".to_string(), script])
.expect("command builds")
.spawn()
.expect("command spawns");
let frames = run(command, Duration::from_millis(1500), 1 << 20).await;
assert!(
matches!(
terminal(&frames),
ProcessFrame::Failed {
code: "timeoutExceeded",
..
}
),
"the command must hit its deadline: {frames:?}"
);
tokio::time::sleep(Duration::from_millis(300)).await;
let after_kill = std::fs::metadata(&marker).map(|m| m.len());
tokio::time::sleep(Duration::from_millis(300)).await;
let later = std::fs::metadata(&marker).map(|m| m.len());
let _ = std::fs::remove_file(&marker);
let after_kill = after_kill.expect("the forked process must have written before the kill");
let later = later.expect("the marker must still exist");
assert!(
after_kill > 0,
"the forked process wrote nothing, so this test proves nothing"
);
assert_eq!(
after_kill, later,
"a process the command forked outlived the deadline and is still writing"
);
}
#[tokio::test]
async fn a_sandboxed_command_does_not_inherit_the_ambient_environment() {
std::env::set_var("ALIEN_SANDBOX_LEAK_PROBE", "leaked");
let environment = BTreeMap::from([("PASSED_IN".to_string(), "yes".to_string())]);
let command = spawn_sandboxed(
"/bin/sh",
&[
"-c".to_string(),
"echo \"ambient=${ALIEN_SANDBOX_LEAK_PROBE:-absent} passed=${PASSED_IN:-absent}\""
.to_string(),
],
&environment,
)
.expect("command builds")
.spawn()
.expect("command spawns");
let frames = run(command, Duration::from_secs(10), 1 << 20).await;
assert!(
matches!(terminal(&frames), ProcessFrame::Exit { code: 0, .. }),
"the probe must actually run: {frames:?}"
);
assert_eq!(
stdout_of(&frames).trim(),
"ambient=absent passed=yes",
"the ambient environment must not cross into a caller's command"
);
}
#[tokio::test]
async fn output_is_followed_by_exactly_one_terminal_frame() {
let frames = run(
child(&["/bin/echo", "hello"]),
Duration::from_secs(10),
1 << 20,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 0, .. }
));
assert_eq!(
frames
.iter()
.filter(|frame| matches!(
frame,
ProcessFrame::Exit { .. } | ProcessFrame::Failed { .. }
))
.count(),
1
);
}
#[tokio::test]
async fn both_streams_share_one_monotonic_sequence() {
let frames = run(
child(&["/bin/sh", "-c", "echo out; echo err 1>&2; echo out2"]),
Duration::from_secs(10),
1 << 20,
)
.await;
let sequence: Vec<u64> = frames
.iter()
.filter_map(|frame| match frame {
ProcessFrame::Output { seq, .. } => Some(*seq),
_ => None,
})
.collect();
let mut sorted = sequence.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), sequence.len(), "no sequence number is reused");
assert!(
frames.iter().any(|frame| matches!(
frame,
ProcessFrame::Output {
stream: ProcessStream::Stderr,
..
}
)),
"stderr must be framed, not dropped"
);
}
#[tokio::test]
async fn a_deadline_ends_a_command_that_would_not() {
let frames = run(
child(&["/bin/sh", "-c", "sleep 30"]),
Duration::from_millis(200),
1 << 20,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Failed {
code: "timeoutExceeded",
..
}
));
}
#[tokio::test]
async fn output_past_the_cap_is_truncated_and_the_terminal_frame_says_so() {
let frames = run(
child(&[
"/bin/sh",
"-c",
"for i in 1 2 3 4 5 6 7 8 9 10; do echo aaaaaaaaaa; done",
]),
Duration::from_secs(10),
8,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit {
code: 0,
truncated: true
}
));
}
#[tokio::test]
async fn a_departed_caller_kills_the_process() {
let (sender, receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = tokio::spawn(stream(
child(&["/bin/sh", "-c", "while true; do echo aaaaaaaa; done"]),
Duration::from_secs(30),
1 << 30,
sender,
));
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!produce.is_finished(),
"an endless command must still be running, not drained into memory"
);
drop(receiver);
tokio::time::timeout(Duration::from_secs(10), produce)
.await
.expect("a command whose caller left must be killed, not left running")
.expect("the producing task must not panic");
}
#[tokio::test]
async fn frames_arrive_before_the_process_exits() {
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = tokio::spawn(stream(
child(&["/bin/sh", "-c", "echo first; sleep 5; echo second"]),
Duration::from_secs(30),
1 << 20,
sender,
));
let first = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
.await
.expect("the first frame must arrive long before the process exits")
.expect("a frame");
assert!(matches!(first, ProcessFrame::Output { .. }));
produce.abort();
}
}