use crate::host::Ran;
use std::ffi::CString;
use std::io::{self, Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const GRACE: Duration = Duration::from_secs(2);
const DRAINED: Duration = Duration::from_secs(5);
pub(crate) fn run_within(
mut command: Command,
handed: Option<&[u8]>,
ceiling: Duration,
) -> io::Result<Ran> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.process_group(0);
let reader = match handed {
Some(bytes) => {
let (reader, mut writer) = io::pipe()?;
writer.write_all(bytes)?;
drop(writer);
Some(reader)
}
None => None,
};
if let Some(reader) = &reader {
let fd = reader.as_raw_fd();
unsafe {
command.pre_exec(move || {
let handed = if fd == 3 {
libc::fcntl(3, libc::F_SETFD, 0)
} else {
libc::dup2(fd, 3)
};
if handed == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
}
let mut child = command.spawn()?;
drop(reader);
let stdout = child.stdout.take();
let (said, printed) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut text = String::new();
if let Some(stdout) = stdout {
let _ = stdout.take(1 << 20).read_to_string(&mut text);
}
let _ = said.send(text);
});
let began = Instant::now();
let mut timed_out = false;
let status = loop {
if let Some(status) = child.try_wait()? {
break Some(status);
}
if began.elapsed() >= ceiling {
timed_out = true;
signal(&child, libc::SIGTERM);
break ended_within(&mut child, GRACE);
}
std::thread::sleep(Duration::from_millis(100));
};
signal(&child, libc::SIGKILL);
let status = match status {
Some(status) => Some(status),
None => child.wait().ok(),
};
Ok(Ran {
exit: status
.and_then(|status| status.code())
.filter(|_| !timed_out),
stdout: printed.recv_timeout(DRAINED).unwrap_or_default(),
timed_out,
})
}
fn ended_within(child: &mut Child, grace: Duration) -> Option<std::process::ExitStatus> {
let began = Instant::now();
while began.elapsed() < grace {
if let Some(status) = child.try_wait().ok().flatten() {
return Some(status);
}
std::thread::sleep(Duration::from_millis(100));
}
None
}
fn signal(child: &Child, sig: libc::c_int) {
let Ok(group) = libc::pid_t::try_from(child.id()) else {
return;
};
unsafe {
libc::killpg(group, sig);
}
}
pub(crate) fn may_be_running(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return true;
};
let probed = unsafe { libc::kill(pid, 0) };
probed == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
pub(crate) fn can_run(path: &Path) -> bool {
let Ok(name) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let executable = unsafe { libc::access(name.as_ptr(), libc::X_OK) } == 0;
executable && std::fs::metadata(path).is_ok_and(|found| found.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_process_is_running_and_one_that_exited_is_not() {
assert!(may_be_running(std::process::id()));
let exited = {
let mut child = std::process::Command::new("true").spawn().unwrap();
let pid = child.id();
child.wait().unwrap();
pid
};
assert!(!may_be_running(exited));
}
#[test]
fn a_program_is_a_regular_file_this_user_may_execute() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("pitboard-can-run-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch directory");
let made = |name: &str, mode: u32| {
let path = dir.join(name);
std::fs::write(&path, "#!/bin/sh\n").expect("a file");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
.expect("its mode");
path
};
let everyone = made("everyone", 0o755);
let mine = made("mine", 0o700);
let theirs = made("theirs", 0o611);
let plain = made("plain", 0o644);
let folder = dir.join("folder");
std::fs::create_dir(&folder).expect("a directory");
let link = dir.join("link");
std::os::unix::fs::symlink(&mine, &link).expect("a link");
let dangling = dir.join("dangling");
std::os::unix::fs::symlink(dir.join("gone"), &dangling).expect("a link");
assert!(can_run(&everyone));
assert!(can_run(&mine));
assert!(can_run(&link), "a link to a program is that program");
assert!(!can_run(&plain));
assert!(!can_run(&folder), "a directory is not a program");
assert!(!can_run(&dangling));
assert!(!can_run(&dir.join("absent")));
if unsafe { libc::geteuid() } != 0 {
assert!(
!can_run(&theirs),
"only its group and others may execute it"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
fn shell(script: &str) -> Command {
let mut command = Command::new("/bin/sh");
command.args(["-c", script]);
command
}
#[test]
fn what_is_handed_arrives_on_descriptor_three_alone() {
let ran = run_within(
shell(r#"printf '%s|' "$(cat <&3)" "$(cat)" "$*""#),
Some(b"the-token"),
Duration::from_secs(10),
)
.expect("ran");
assert_eq!(ran.exit, Some(0));
assert_eq!(ran.stdout, "the-token|||");
assert!(!ran.timed_out);
}
#[test]
fn with_nothing_handed_descriptor_three_is_closed() {
let ran = run_within(
shell("cat <&3 2>/dev/null; echo $?"),
None,
Duration::from_secs(10),
)
.expect("ran");
assert_ne!(ran.stdout.trim(), "0");
}
#[test]
fn a_run_past_the_ceiling_is_stopped_with_its_children() {
let marker =
std::env::temp_dir().join(format!("pitboard-run-within-{}", std::process::id()));
let _ = std::fs::remove_file(&marker);
let script = format!("(sleep 3; touch {}) & sleep 30", marker.display());
let began = Instant::now();
let ran = run_within(shell(&script), None, Duration::from_secs(1)).expect("ran");
assert!(ran.timed_out);
assert_eq!(ran.exit, None);
assert!(began.elapsed() < Duration::from_secs(10));
std::thread::sleep(Duration::from_secs(4));
assert!(!marker.exists(), "the child it started was stopped too");
}
#[test]
fn what_a_run_left_behind_holding_its_output_is_stopped() {
let began = Instant::now();
let ran =
run_within(shell("sleep 30 & echo done"), None, Duration::from_secs(60)).expect("ran");
assert_eq!(ran.exit, Some(0));
assert_eq!(ran.stdout, "done\n");
assert!(began.elapsed() < Duration::from_secs(8));
}
}