use std::io::Read;
use std::path::PathBuf;
use std::process::{Child, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub mod helper;
pub mod jail;
pub mod protocol;
pub mod worker;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "macos")]
pub mod macos;
use jail::{JailBackend, SpawnSpec};
use protocol::{FrameRead, Request, RequestSchema, Response};
#[derive(Debug, Clone)]
pub struct Limits {
pub wall_timeout: Duration,
pub max_response_bytes: u32,
pub max_stderr_bytes: u64,
pub address_space_bytes: u64,
pub cpu_seconds: u64,
pub file_size_bytes: u64,
pub max_processes: u64,
}
impl Default for Limits {
fn default() -> Limits {
Limits {
wall_timeout: Duration::from_secs(120),
max_response_bytes: 192 * 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
address_space_bytes: 4 * 1024 * 1024 * 1024,
cpu_seconds: 300,
file_size_bytes: 512 * 1024 * 1024,
max_processes: 16,
}
}
}
#[derive(Debug, Clone)]
pub struct RunnerError {
pub code: &'static str,
pub message: String,
}
impl std::fmt::Display for RunnerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for RunnerError {}
pub const WORKER_BINARY: &str = "text-mirror-worker";
pub const SANDBOX_SETUP_EXIT: i32 = 71;
pub fn locate_worker() -> Result<PathBuf, RunnerError> {
let missing = |detail: String| RunnerError {
code: "worker_not_found",
message: detail,
};
let current = std::env::current_exe()
.map_err(|e| missing(format!("cannot resolve the current executable: {e}")))?;
let Some(dir) = current.parent() else {
return Err(missing(
"the current executable has no directory".to_string(),
));
};
let mut candidates = vec![dir.join(WORKER_BINARY)];
if let Some(parent) = dir.parent() {
candidates.push(parent.join(WORKER_BINARY));
}
for candidate in &candidates {
if candidate.is_file() {
return candidate
.canonicalize()
.map_err(|e| missing(format!("cannot canonicalize {}: {e}", candidate.display())));
}
}
Err(missing(format!(
"no {WORKER_BINARY} beside {}",
current.display()
)))
}
pub struct Runner {
backend: Box<dyn JailBackend>,
worker: PathBuf,
limits: Limits,
}
impl Runner {
pub fn new(backend: Box<dyn JailBackend>, worker: PathBuf, limits: Limits) -> Runner {
Runner {
backend,
worker,
limits,
}
}
pub fn with_platform_defaults() -> Result<Runner, RunnerError> {
Ok(Runner::new(
jail::platform_backend()?,
locate_worker()?,
Limits::default(),
))
}
pub fn backend(&self) -> &dyn JailBackend {
self.backend.as_ref()
}
pub fn limits(&self) -> &Limits {
&self.limits
}
pub fn policy_digest(&self) -> Result<String, RunnerError> {
jail::policy_digest(self.backend.as_ref(), &self.worker)
}
pub fn run(
&self,
mode: &str,
payload: serde_json::Value,
files: &[(&str, &[u8])],
) -> Result<Response, RunnerError> {
let digest = self.policy_digest()?;
jail::ensure_probed(self, &digest)?;
self.run_unprobed(mode, payload, files, true)
}
pub(crate) fn run_unprobed(
&self,
mode: &str,
payload: serde_json::Value,
files: &[(&str, &[u8])],
seccomp: bool,
) -> Result<Response, RunnerError> {
let jail_dir = tempfile::tempdir().map_err(|e| RunnerError {
code: "adapter_spawn_error",
message: format!("cannot create the jail directory: {e}"),
})?;
let jail_path = jail_dir.path().canonicalize().map_err(|e| RunnerError {
code: "adapter_spawn_error",
message: format!("cannot canonicalize the jail directory: {e}"),
})?;
for (name, bytes) in files {
if name.contains('/') || name.contains("..") {
return Err(RunnerError {
code: "adapter_spawn_error",
message: format!("input name {name:?} is not a bare file name"),
});
}
std::fs::write(jail_path.join(name), bytes).map_err(|e| RunnerError {
code: "adapter_spawn_error",
message: format!("cannot stage input {name:?}: {e}"),
})?;
}
let spec = SpawnSpec {
worker: &self.worker,
jail: &jail_path,
mode,
limits: &self.limits,
seccomp,
};
let mut command = self.backend.command(&spec)?;
command
.env_clear()
.current_dir(&jail_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command.spawn().map_err(|e| RunnerError {
code: "adapter_spawn_error",
message: format!("cannot spawn the jailed worker: {e}"),
})?;
let request = Request {
schema: RequestSchema,
adapter: mode.to_string(),
payload,
};
if let Some(mut stdin) = child.stdin.take() {
let _ = protocol::write_frame(&mut stdin, &request);
}
drive(&mut child, &self.limits)
}
}
enum StdoutEnd {
Frame(Vec<u8>),
Empty,
Truncated,
Oversized { declared: u64 },
Trailing,
ReadError(String),
}
const DRAIN_JOIN_BOUND: Duration = Duration::from_secs(2);
fn drive(child: &mut Child, limits: &Limits) -> Result<Response, RunnerError> {
let kill_now = Arc::new(AtomicBool::new(false));
let stdout = child.stdout.take().expect("stdout was piped");
let stdout_kill = kill_now.clone();
let max_response = limits.max_response_bytes;
let (stdout_done, stdout_result) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut reader = stdout;
let end = match protocol::read_frame(&mut reader, max_response) {
Ok(FrameRead::Complete(payload)) => {
let mut probe = [0u8; 1];
match reader.read(&mut probe) {
Ok(0) => StdoutEnd::Frame(payload),
Ok(_) => StdoutEnd::Trailing,
Err(e) => StdoutEnd::ReadError(e.to_string()),
}
}
Ok(FrameRead::Empty) => StdoutEnd::Empty,
Ok(FrameRead::Truncated) => StdoutEnd::Truncated,
Ok(FrameRead::Oversized { declared }) => StdoutEnd::Oversized { declared },
Err(e) => StdoutEnd::ReadError(e.to_string()),
};
if matches!(
end,
StdoutEnd::Oversized { .. } | StdoutEnd::Trailing | StdoutEnd::ReadError(_)
) {
stdout_kill.store(true, Ordering::SeqCst);
}
if matches!(end, StdoutEnd::Oversized { .. } | StdoutEnd::Trailing) {
let mut sink = [0u8; 8192];
while matches!(reader.read(&mut sink), Ok(n) if n > 0) {}
}
let _ = stdout_done.send(end);
});
let stderr = child.stderr.take().expect("stderr was piped");
let stderr_kill = kill_now.clone();
let max_stderr = limits.max_stderr_bytes;
let (stderr_done, stderr_result) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut reader = stderr;
let mut kept = Vec::new();
let mut total: u64 = 0;
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
total += n as u64;
if kept.len() < 4096 {
let take = (4096 - kept.len()).min(n);
kept.extend_from_slice(&chunk[..take]);
}
if total > max_stderr {
stderr_kill.store(true, Ordering::SeqCst);
}
}
Err(_) => break,
}
}
let _ = stderr_done.send((kept, total));
});
let deadline = Instant::now() + limits.wall_timeout;
let mut timed_out = false;
let status = loop {
if let Some(status) = child.try_wait().ok().flatten() {
break status;
}
if Instant::now() >= deadline {
timed_out = true;
kill_group(child);
break wait_after_kill(child);
}
if kill_now.load(Ordering::SeqCst) {
kill_group(child);
break wait_after_kill(child);
}
std::thread::sleep(Duration::from_millis(2));
};
kill_group(child);
let join_deadline = Instant::now() + DRAIN_JOIN_BOUND;
let stdout_end = stdout_result.recv_timeout(DRAIN_JOIN_BOUND).ok();
let stderr_end = stderr_result
.recv_timeout(join_deadline.saturating_duration_since(Instant::now()))
.ok();
let (Some(stdout_end), Some((stderr_kept, stderr_total))) = (stdout_end, stderr_end) else {
return Err(RunnerError {
code: "adapter_drain_timeout",
message: format!(
"a pipe stayed open {DRAIN_JOIN_BOUND:?} after the group kill, so a descendant escaped the process group; the drain is detached and all output is discarded"
),
});
};
if timed_out {
return Err(RunnerError {
code: "adapter_timeout",
message: format!(
"killed the process group after the {:?} wall-clock ceiling, partial output discarded",
limits.wall_timeout
),
});
}
if let StdoutEnd::Oversized { declared } = stdout_end {
return Err(RunnerError {
code: "adapter_frame_oversized",
message: format!(
"response frame declared {declared} bytes over the {} byte ceiling, refused before allocation",
limits.max_response_bytes
),
});
}
if stderr_total > limits.max_stderr_bytes {
return Err(RunnerError {
code: "adapter_output_overflow",
message: format!(
"stderr wrote {stderr_total} bytes over the {} byte cap",
limits.max_stderr_bytes
),
});
}
if matches!(stdout_end, StdoutEnd::Trailing) {
return Err(RunnerError {
code: "adapter_output_overflow",
message: "bytes after the response frame, one frame is the whole stdout budget"
.to_string(),
});
}
if !status.success() {
let detail = exit_detail(&status);
let stderr_text = String::from_utf8_lossy(&stderr_kept);
let stderr_text = stderr_text.trim();
#[cfg(unix)]
let setup_failed = status.code() == Some(SANDBOX_SETUP_EXIT);
#[cfg(not(unix))]
let setup_failed = false;
if setup_failed {
return Err(RunnerError {
code: "sandbox_unavailable",
message: format!("the sandbox helper refused to engage the jail: {stderr_text}"),
});
}
return Err(RunnerError {
code: "adapter_crash",
message: if stderr_text.is_empty() {
format!("worker {detail}, any response frame is discarded")
} else {
format!("worker {detail}: {stderr_text}")
},
});
}
let payload = match stdout_end {
StdoutEnd::Frame(payload) => payload,
StdoutEnd::Empty => {
return Err(RunnerError {
code: "adapter_protocol_error",
message: "worker exited without a response frame".to_string(),
});
}
StdoutEnd::Truncated => {
return Err(RunnerError {
code: "adapter_protocol_error",
message: "worker exited with a truncated response frame".to_string(),
});
}
StdoutEnd::ReadError(detail) => {
return Err(RunnerError {
code: "adapter_protocol_error",
message: format!("reading the response failed: {detail}"),
});
}
StdoutEnd::Oversized { .. } | StdoutEnd::Trailing => unreachable!("handled above"),
};
let response: Response = serde_json::from_slice(&payload).map_err(|e| RunnerError {
code: "adapter_protocol_error",
message: format!("response frame did not parse: {e}"),
})?;
response.validate().map_err(|detail| RunnerError {
code: "adapter_protocol_error",
message: detail,
})?;
Ok(response)
}
fn kill_group(child: &mut Child) {
#[cfg(unix)]
{
if let Ok(pid) = i32::try_from(child.id()) {
let _ = nix::sys::signal::killpg(
nix::unistd::Pid::from_raw(pid),
nix::sys::signal::Signal::SIGKILL,
);
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
}
fn wait_after_kill(child: &mut Child) -> std::process::ExitStatus {
child.wait().unwrap_or_else(|_| {
child
.try_wait()
.ok()
.flatten()
.expect("killed child has an exit status")
})
}
fn exit_detail(status: &std::process::ExitStatus) -> String {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return format!("died on signal {signal}");
}
}
match status.code() {
Some(code) => format!("exited with code {code}"),
None => "exited without a status".to_string(),
}
}