Files
hunjing/sandbox/src/executor.rs
T
2026-08-21 16:00:13 +08:00

1230 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 沙箱执行内核:以受限子进程方式运行外部命令,提供超时、取消、流式输出与进程组清理。
//!
//! 当前阶段隔离措施(跨 Linux / macOS 可用):
//! - 独立进程组:超时或取消时对整个进程组发送 SIGKILL,清理其派生的子进程。
//! - 环境隔离:默认清空继承环境,仅注入白名单 + 请求指定变量。
//! - 工作目录隔离:默认每次执行使用 base 下的临时目录,执行后回收;可指定 base 内子目录(拒绝路径逃逸)。
//! - 进程级资源限额:fork 后、exec 前 setrlimit 限制 CPU / 内存 / 文件 / 进程数 / fd(见 rlimits 模块)。
//! - 超时强制:达到上限(min(请求, 全局))即 SIGKILL 进程组。
//! - 取消:调用方持 trace_id 经 ``cancel`` 中止运行中任务(SIGKILL 进程组),产出标记 ``cancelled``。
//! - 流式输出:``run_stream`` 经 mpsc channel 实时推送 stdout/stderr 增量,供 SSE 端点转发。
//! - 输出截断:stdout / stderr 各自截断至上限,超出标记 truncated 并继续 drain 至 EOF 以防管道阻塞。
//! - 可观测:每次执行记录 Prometheus 指标(计数/耗时/并发/流式)。
//!
//! 容器化部署时可在 Docker 侧叠加 seccomp / cgroup v2 资源限制(见 docker/sandbox/Dockerfile 与 sandbox/README.md)。
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot, Mutex};
use crate::config::Config;
use crate::docker_exec::{self, detect_language_from_argv};
use crate::metrics::Metrics;
use crate::rlimits::EffectiveRlimit;
use crate::warm_pool::WarmPool;
/// 执行错误:`InvalidArgs` 映射 HTTP 400(客户端可修正),`Internal` 映射 HTTP 500。
#[derive(Debug)]
pub enum ExecError {
/// 请求参数不合法,例如 argv 为空、workdir 路径逃逸。
InvalidArgs(String),
/// 内部错误:`user_msg` 返回客户端(通用、脱敏,不含路径/errno),
/// `detail` 含底层细节只进 tracing 日志(带 trace_id 关联)。
Internal { user_msg: &'static str, detail: String },
}
impl std::fmt::Display for ExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExecError::InvalidArgs(m) => write!(f, "参数错误: {m}"),
ExecError::Internal { user_msg, .. } => write!(f, "{user_msg}"),
}
}
}
impl std::error::Error for ExecError {}
/// 运行中任务的句柄:持有 pid 与取消信号发送端,供 ``cancel`` 中止。
struct RunningHandle {
#[allow(dead_code)]
pid: u32,
cancel_tx: Option<oneshot::Sender<()>>,
}
/// 已 spawn 的子进程及其管道、取消信号与临时工作目录持有者。
struct SpawnedChild {
child: tokio::process::Child,
pid: Option<u32>,
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
cancel_rx: oneshot::Receiver<()>,
start: Instant,
/// 临时工作目录:持有到子进程退出后才回收(None 表示用 base 内持久子目录)。
_temp_keep: Option<tempfile::TempDir>,
}
/// 流式执行事件:stdout / stderr 增量,或执行结束的汇总。
pub enum StreamEvent {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Done(ExecOutcome),
}
#[derive(Clone, Copy)]
enum OutputKind {
Stdout,
Stderr,
}
pub struct Executor {
config: Arc<Config>,
base_canon: PathBuf,
/// 运行中任务注册表:trace_id → 句柄。cancel 端点据此定位并 SIGKILL 进程组。
running: Arc<Mutex<HashMap<String, RunningHandle>>>,
metrics: Arc<Metrics>,
}
/// 单次执行的产出。
#[derive(Debug)]
pub struct ExecOutcome {
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration_ms: u128,
pub timeout: bool,
pub cancelled: bool,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
/// 把执行结果分类为 Prometheus 标签值。
fn classify_outcome(o: &ExecOutcome) -> &'static str {
if o.cancelled {
"cancelled"
} else if o.timeout {
"timeout"
} else if o.exit_code == Some(0) {
"completed"
} else {
"failed"
}
}
impl Executor {
pub fn new(config: Arc<Config>) -> std::io::Result<Self> {
let base_canon = config
.base_workdir
.canonicalize()
.unwrap_or_else(|_| config.base_workdir.clone());
let metrics = Arc::new(Metrics::new(config.max_concurrent, config.auth_enabled));
Ok(Self {
config,
base_canon,
running: Arc::new(Mutex::new(HashMap::new())),
metrics,
})
}
/// 暴露指标集合(供 /metrics 端点 gather)。
pub fn metrics(&self) -> &Arc<Metrics> {
&self.metrics
}
/// 沙箱工作根目录(供文件 IO 端点复用路径校验与读写)。
pub fn base(&self) -> &std::path::Path {
&self.config.base_workdir
}
/// 沙箱工作根目录的 canonical 形式(路径逃逸校验基准)。
pub fn base_canon(&self) -> &std::path::Path {
&self.base_canon
}
/// 取消运行中的任务。返回是否命中(未命中表示任务不存在或已完成)。
/// 命中后向 run()/run_stream() 的 select! 发送取消信号,由其 SIGKILL 进程组并标记 cancelled。
pub async fn cancel(&self, trace_id: &str) -> bool {
let handle = self.running.lock().await.remove(trace_id);
if let Some(h) = handle {
if let Some(tx) = h.cancel_tx {
let _ = tx.send(());
}
true
} else {
false
}
}
/// 共享的 spawn 前置:工作目录解析、子进程组装、stdin 写入、注册 cancel。
/// 返回的 SpawnedChild 持有 child 与管道;调用方负责 select! 等待与注销 trace_id。
async fn spawn_tracked(
&self,
argv: Vec<String>,
stdin: Option<String>,
workdir: Option<String>,
env: Vec<(String, String)>,
trace_id: String,
effective: Vec<EffectiveRlimit>,
) -> Result<SpawnedChild, ExecError> {
if argv.is_empty() {
return Err(ExecError::InvalidArgs("argv 不能为空".into()));
}
// 1) 工作目录:临时目录或 base 内子目录(拒绝逃逸)
let (workdir, temp_keep) = match workdir.as_deref() {
None => {
let td = tempfile::TempDir::new_in(&self.config.base_workdir).map_err(|e| {
ExecError::Internal {
user_msg: "工作目录不可用",
detail: format!("创建临时工作目录失败: {e}"),
}
})?;
(td.path().to_path_buf(), Some(td))
}
Some(rel) => {
let candidate = self.config.base_workdir.join(rel);
tokio::fs::create_dir_all(&candidate).await.map_err(|e| {
ExecError::Internal {
user_msg: "工作目录不可用",
detail: format!("创建工作目录失败: {e}"),
}
})?;
let canon = tokio::fs::canonicalize(&candidate)
.await
.map_err(|e| ExecError::Internal {
user_msg: "工作目录不可用",
detail: format!("解析工作目录失败: {e}"),
})?;
if !canon.starts_with(&self.base_canon) {
return Err(ExecError::InvalidArgs(format!(
"workdir 逃逸出沙箱根目录: {rel}"
)));
}
(canon, None)
}
};
// 2) 组装子进程
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
cmd.env_clear();
// env_clear 后始终注入兜底 PATH,避免子进程找不到外部命令。
// allowed_env 若有 PATH 会覆盖此默认值。
cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
for k in &self.config.allowed_env {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
for (k, v) in &env {
cmd.env(k, v);
}
cmd.current_dir(&workdir);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.process_group(0); // 子进程成为新进程组 leader,pgid == pid
// 进程级资源限额:fork 后、exec 前 setrlimit(容器级隔离之外的纵深防御)。
// 非 unix 平台无 setrlimit 等价物,跳过(rlimits::apply 已是空操作)。
#[cfg(unix)]
{
let is_root = unsafe { libc::getuid() } == 0;
let eff = effective.clone();
// pre_exec 闭包在 fork 后、exec 前的子进程运行,仅调用 async-signal-safe 的 setrlimit,
// 不分配内存、不持锁;tokio 将其标记 unsafe 以强制调用方确认这些约束。
unsafe {
cmd.pre_exec(move || crate::rlimits::apply(&eff, is_root));
}
}
#[cfg(not(unix))]
{
let _ = &effective;
}
let start = Instant::now();
// 重试逻辑:EAGAIN / ENOMEM / EMFILE 等瞬态错误做指数退避重试
let retryable_errnos = [libc::EAGAIN, libc::ENOMEM, libc::EMFILE];
let retry_delays = [
Duration::from_millis(200),
Duration::from_millis(400),
Duration::from_millis(800),
];
let mut child = {
let mut result = cmd.spawn();
let mut attempt = 0usize;
loop {
match result {
Ok(child) => break child,
Err(e) => {
let is_retryable = e
.raw_os_error()
.map(|errno| retryable_errnos.contains(&errno))
.unwrap_or(false);
if !is_retryable {
return Err(ExecError::Internal {
user_msg: "无法启动进程",
detail: format!("spawn 失败: {e}"),
});
}
if attempt >= retry_delays.len() {
return Err(ExecError::Internal {
user_msg: "无法启动进程",
detail: format!(
"spawn 失败(已重试 {} 次): {e}",
retry_delays.len()
),
});
}
tracing::warn!(
attempt = attempt + 1,
max_retries = retry_delays.len(),
error = %e,
"spawn 重试(进程/文件/内存资源不足)"
);
tokio::time::sleep(retry_delays[attempt]).await;
result = cmd.spawn();
attempt += 1;
}
}
}
};
// 3) 写入 stdin(若有)
if let Some(input) = stdin {
if let Some(mut child_stdin) = child.stdin.take() {
tokio::spawn(async move {
let _ = child_stdin.write_all(input.as_bytes()).await;
let _ = child_stdin.shutdown().await;
});
}
} else {
drop(child.stdin.take());
}
let pid = child.id();
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
// 4) 注册运行中任务 + 取消信号
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
{
let mut map = self.running.lock().await;
map.insert(
trace_id.clone(),
RunningHandle {
pid: pid.unwrap_or(0),
cancel_tx: Some(cancel_tx),
},
);
}
Ok(SpawnedChild {
child,
pid,
stdout,
stderr,
cancel_rx,
start,
_temp_keep: temp_keep,
})
}
/// 同步执行:收集全部 stdout/stderr 后返回。
#[allow(clippy::too_many_arguments)]
pub async fn run(
&self,
argv: Vec<String>,
stdin: Option<String>,
timeout_ms: Option<u64>,
workdir: Option<String>,
env: Vec<(String, String)>,
max_output_bytes: Option<usize>,
trace_id: String,
effective: Vec<EffectiveRlimit>,
) -> Result<ExecOutcome, ExecError> {
let global_ms = self.config.max_timeout.as_millis() as u64;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(global_ms).min(global_ms));
let max_out = max_output_bytes.unwrap_or(self.config.max_output_bytes);
let SpawnedChild {
mut child,
pid,
stdout,
stderr,
cancel_rx,
start,
_temp_keep,
} = self
.spawn_tracked(argv, stdin, workdir, env, trace_id.clone(), effective)
.await?;
// spawn 成功:计入并发
self.metrics.concurrent_inc();
let out_task = tokio::spawn(capture_limited(stdout, max_out));
let err_task = tokio::spawn(capture_limited(stderr, max_out));
let mut timeout_hit = false;
let mut cancelled = false;
let status: Option<std::process::ExitStatus> = tokio::select! {
res = child.wait() => match res {
Ok(s) => Some(s),
Err(e) => {
self.running.lock().await.remove(&trace_id);
self.metrics.concurrent_dec();
return Err(ExecError::Internal {
user_msg: "执行失败",
detail: format!("wait 子进程失败: {e}"),
});
}
},
_ = tokio::time::sleep(timeout) => {
timeout_hit = true;
if let Some(pid) = pid {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
}
child.wait().await.ok()
}
_ = cancel_rx => {
cancelled = true;
if let Some(pid) = pid {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
}
child.wait().await.ok()
}
};
self.running.lock().await.remove(&trace_id);
drop(_temp_keep);
let (out_bytes, out_trunc) = out_task.await.unwrap_or_default();
let (err_bytes, err_trunc) = err_task.await.unwrap_or_default();
let outcome = ExecOutcome {
exit_code: status.and_then(|s| s.code()),
stdout: String::from_utf8_lossy(&out_bytes).into_owned(),
stderr: String::from_utf8_lossy(&err_bytes).into_owned(),
duration_ms: start.elapsed().as_millis(),
timeout: timeout_hit,
cancelled,
stdout_truncated: out_trunc,
stderr_truncated: err_trunc,
};
self.metrics.concurrent_dec();
self.metrics
.record_exec(classify_outcome(&outcome), outcome.duration_ms);
Ok(outcome)
}
/// 流式执行:返回事件 Receiver,stdout/stderr 增量实时推送,末尾一个 Done。
/// `permit` 持有到驱动 task 结束(并发闸),由调用方从 Semaphore acquire 后传入。
#[allow(clippy::too_many_arguments)]
pub async fn run_stream(
&self,
argv: Vec<String>,
stdin: Option<String>,
timeout_ms: Option<u64>,
workdir: Option<String>,
env: Vec<(String, String)>,
max_output_bytes: Option<usize>,
trace_id: String,
permit: Option<tokio::sync::OwnedSemaphorePermit>,
effective: Vec<EffectiveRlimit>,
) -> Result<mpsc::Receiver<StreamEvent>, ExecError> {
let global_ms = self.config.max_timeout.as_millis() as u64;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(global_ms).min(global_ms));
let max_out = max_output_bytes.unwrap_or(self.config.max_output_bytes);
let SpawnedChild {
child,
pid,
stdout,
stderr,
cancel_rx,
start,
_temp_keep,
} = self
.spawn_tracked(argv, stdin, workdir, env, trace_id.clone(), effective)
.await?;
// spawn 成功:计入并发
self.metrics.concurrent_inc();
let (tx, rx) = mpsc::channel::<StreamEvent>(64);
let out_task =
tokio::spawn(capture_streaming(stdout, max_out, OutputKind::Stdout, tx.clone()));
let err_task =
tokio::spawn(capture_streaming(stderr, max_out, OutputKind::Stderr, tx.clone()));
let running = self.running.clone();
let metrics = self.metrics.clone();
let done_trace_id = trace_id.clone();
tokio::spawn(async move {
let _permit = permit;
let _temp_keep = _temp_keep;
let mut child = child;
// 客户端断开探测:`tx.closed()` 在接收端(rx)drop 时完成(== SSE 断连)。
// 对有输出命令,capture_streaming 的 send 也会先一步感知;这里覆盖无输出
// 长命令(如 sleep 30)——单纯靠 send err 检测会漏掉。单次 select! 即可,无需轮询。
let mut timeout_hit = false;
let mut cancelled = false;
let status: Option<std::process::ExitStatus> = tokio::select! {
res = child.wait() => res.ok(),
_ = tokio::time::sleep(timeout) => {
timeout_hit = true;
if let Some(pid) = pid {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
}
child.wait().await.ok()
}
_ = cancel_rx => {
cancelled = true;
if let Some(pid) = pid {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
}
child.wait().await.ok()
}
_ = tx.closed() => {
// 接收端已关闭:客户端断开,取消子进程并收尸
cancelled = true;
if let Some(pid) = pid {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
}
child.wait().await.ok()
}
};
running.lock().await.remove(&done_trace_id);
let out_trunc = out_task.await.unwrap_or(false);
let err_trunc = err_task.await.unwrap_or(false);
let outcome = ExecOutcome {
exit_code: status.and_then(|s| s.code()),
stdout: String::new(),
stderr: String::new(),
duration_ms: start.elapsed().as_millis(),
timeout: timeout_hit,
cancelled,
stdout_truncated: out_trunc,
stderr_truncated: err_trunc,
};
metrics.concurrent_dec();
metrics.record_stream(classify_outcome(&outcome));
let _ = tx.send(StreamEvent::Done(outcome)).await;
});
Ok(rx)
}
/// 统一入口:根据隔离模式路由到进程或容器执行路径。
#[allow(clippy::too_many_arguments)]
pub async fn run_routed(
&self,
argv: Vec<String>,
stdin: Option<String>,
timeout_ms: Option<u64>,
workdir: Option<String>,
env: Vec<(String, String)>,
max_output_bytes: Option<usize>,
trace_id: String,
effective: Vec<EffectiveRlimit>,
isolation_mode: &str,
warm_pool: Option<Arc<WarmPool>>,
allow_fallback: bool,
) -> Result<ExecOutcome, ExecError> {
match (isolation_mode, warm_pool) {
("container", Some(pool)) => {
match self.run_container(pool, &argv, &stdin, timeout_ms, &env, max_output_bytes, &trace_id).await {
Ok(outcome) => Ok(outcome),
Err(e) if allow_fallback => {
tracing::warn!(
error = %e,
trace_id = %trace_id,
"容器执行失败,降级为进程模式"
);
self.metrics.record_downgrade("container_fallback");
self.run(argv, stdin, timeout_ms, workdir, env, max_output_bytes, trace_id, effective).await
}
Err(e) => {
tracing::warn!(
error = %e,
trace_id = %trace_id,
"容器执行失败(hard isolation policy,不降级)"
);
self.metrics.record_downgrade("container_hard_fail");
Err(e)
}
}
}
_ => {
self.run(argv, stdin, timeout_ms, workdir, env, max_output_bytes, trace_id, effective).await
}
}
}
/// 容器执行路径(支持取消)。
async fn run_container(
&self,
pool: Arc<WarmPool>,
argv: &[String],
stdin: &Option<String>,
timeout_ms: Option<u64>,
env: &[(String, String)],
max_output_bytes: Option<usize>,
trace_id: &str,
) -> Result<ExecOutcome, ExecError> {
let global_ms = self.config.max_timeout.as_millis() as u64;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(global_ms).min(global_ms));
let max_out = max_output_bytes.unwrap_or(self.config.max_output_bytes);
let lang = detect_language_from_argv(argv);
let handle = pool
.clone()
.acquire(lang)
.await
.map_err(|e| ExecError::Internal {
user_msg: "容器资源不可用",
detail: format!("warm_pool acquire 失败: {e}"),
})?;
let start = Instant::now();
// 注册到 running map 支持取消
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
{
let mut map = self.running.lock().await;
map.remove(trace_id);
map.insert(
trace_id.to_string(),
RunningHandle {
pid: 0,
cancel_tx: Some(cancel_tx),
},
);
}
let outcome = tokio::select! {
result = docker_exec::exec_in_container(
pool.docker(),
&handle.id,
argv,
stdin.clone(),
env,
timeout,
max_out,
) => {
match result {
Ok(o) => o,
Err(e) => {
self.running.lock().await.remove(trace_id);
pool.release(handle).await;
return Err(e);
}
}
}
_ = cancel_rx => {
// 用户取消:杀死容器(exec 随之终止)
let _ = pool.docker()
.kill_container(&handle.id, None::<bollard::container::KillContainerOptions<String>>)
.await;
ExecOutcome {
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration_ms: start.elapsed().as_millis(),
timeout: false,
cancelled: true,
stdout_truncated: false,
stderr_truncated: false,
}
}
};
self.metrics
.record_container_exec(lang, outcome.duration_ms);
self.running.lock().await.remove(trace_id);
pool.release(handle).await;
Ok(outcome)
}
/// 流式容器执行路径。
#[allow(clippy::too_many_arguments)]
pub async fn run_stream_routed(
&self,
argv: Vec<String>,
stdin: Option<String>,
timeout_ms: Option<u64>,
workdir: Option<String>,
env: Vec<(String, String)>,
max_output_bytes: Option<usize>,
trace_id: String,
effective: Vec<EffectiveRlimit>,
isolation_mode: &str,
warm_pool: Option<Arc<WarmPool>>,
permit: Option<tokio::sync::OwnedSemaphorePermit>,
allow_fallback: bool,
) -> Result<mpsc::Receiver<StreamEvent>, ExecError> {
match (isolation_mode, warm_pool) {
("container", Some(pool)) => {
match self.run_stream_container(pool, &argv, &stdin, timeout_ms, &env, max_output_bytes, &trace_id).await {
Ok(rx) => Ok(rx),
Err(e) if allow_fallback => {
tracing::warn!(
error = %e,
trace_id = %trace_id,
"流式容器执行失败,降级为进程模式"
);
self.metrics.record_downgrade("container_stream_fallback");
self.run_stream(argv, stdin, timeout_ms, workdir, env, max_output_bytes, trace_id, permit, effective).await
}
Err(e) => {
tracing::warn!(
error = %e,
trace_id = %trace_id,
"流式容器执行失败(hard isolation policy,不降级)"
);
self.metrics.record_downgrade("container_stream_hard_fail");
Err(e)
}
}
}
_ => {
self.run_stream(argv, stdin, timeout_ms, workdir, env, max_output_bytes, trace_id, permit, effective).await
}
}
}
/// 流式容器执行。
async fn run_stream_container(
&self,
pool: Arc<WarmPool>,
argv: &[String],
stdin: &Option<String>,
timeout_ms: Option<u64>,
env: &[(String, String)],
max_output_bytes: Option<usize>,
_trace_id: &str,
) -> Result<mpsc::Receiver<StreamEvent>, ExecError> {
let global_ms = self.config.max_timeout.as_millis() as u64;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(global_ms).min(global_ms));
let max_out = max_output_bytes.unwrap_or(self.config.max_output_bytes);
let lang = detect_language_from_argv(argv);
let handle = pool
.clone()
.acquire(lang)
.await
.map_err(|e| ExecError::Internal {
user_msg: "容器资源不可用",
detail: format!("warm_pool acquire 失败: {e}"),
})?;
let rx = docker_exec::exec_in_container_stream(
pool.docker(),
&handle.id,
argv,
stdin.clone(),
env,
timeout,
max_out,
)
.await?;
pool.release(handle).await;
Ok(rx)
}
}
/// 读取流到 `max` 字节即停止存储,但继续 drain 至 EOF 以防管道阻塞导致子进程挂死。
async fn capture_limited<R: AsyncRead + Unpin>(reader: R, max: usize) -> (Vec<u8>, bool) {
let mut reader = reader;
let mut buf = Vec::new();
let mut truncated = false;
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => {
if buf.len() < max {
let room = max - buf.len();
if n <= room {
buf.extend_from_slice(&chunk[..n]);
} else {
buf.extend_from_slice(&chunk[..room]);
truncated = true;
}
} else {
truncated = true;
}
}
Err(_) => break,
}
}
(buf, truncated)
}
/// 流式读取:把不超过 `max` 的增量发往 channel,超出后继续 drain 到 EOF(防管道阻塞),返回是否截断。
async fn capture_streaming<R: AsyncRead + Unpin>(
reader: R,
max: usize,
kind: OutputKind,
tx: mpsc::Sender<StreamEvent>,
) -> bool {
let mut reader = reader;
let mut written = 0usize;
let mut truncated = false;
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => {
if written < max {
let room = max - written;
let take = n.min(room);
if take > 0 {
let event = match kind {
OutputKind::Stdout => StreamEvent::Stdout(chunk[..take].to_vec()),
OutputKind::Stderr => StreamEvent::Stderr(chunk[..take].to_vec()),
};
if tx.send(event).await.is_err() {
break; // 接收端已关闭(客户端断开)
}
}
written += take;
if n > room {
truncated = true;
}
} else {
truncated = true;
}
}
Err(_) => break,
}
}
truncated
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cfg(base: PathBuf) -> Config {
Config {
host: "127.0.0.1".into(),
port: 0,
base_workdir: base,
max_timeout: Duration::from_millis(2000),
max_concurrent: 4,
max_output_bytes: 1024,
max_file_bytes: 4096,
allowed_env: vec!["PATH".into()],
auth_enabled: false,
auth_secret: String::new(),
auth_secret_previous: vec![],
rlimits: crate::rlimits::RlimitsConfig::new(),
log_dir: PathBuf::from("../logs"),
isolation_default: "process".into(),
docker_enabled: false,
warm_pool: crate::config::WarmPoolConfig::default(),
}
}
fn sh() -> String {
if std::path::Path::new("/bin/sh").exists() {
"/bin/sh".into()
} else {
"sh".into()
}
}
#[tokio::test]
async fn runs_echo_and_captures_stdout() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let out = ex
.run(
vec![sh(), "-c".into(), "echo hello".into()],
None,
None,
None,
vec![],
None,
"trace_echo".into(),
vec![],
)
.await
.unwrap();
assert!(!out.timeout);
assert!(!out.cancelled);
assert_eq!(out.exit_code, Some(0));
assert_eq!(out.stdout.trim(), "hello");
}
#[tokio::test]
async fn timeout_kills_process_group() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let out = ex
.run(
vec![sh(), "-c".into(), "sleep 30".into()],
None,
Some(200),
None,
vec![],
None,
"trace_timeout".into(),
vec![],
)
.await
.unwrap();
assert!(out.timeout);
assert!(out.duration_ms < 5000, "应在 200ms 内被强制结束");
}
#[tokio::test]
async fn cancel_aborts_running_task() {
let dir = tempfile::tempdir().unwrap();
let ex = Arc::new(Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap());
let trace = "trace_cancel".to_string();
let ex2 = ex.clone();
let trace2 = trace.clone();
let handle = tokio::spawn(async move {
ex2.run(
vec![sh(), "-c".into(), "sleep 30".into()],
None,
Some(30_000),
None,
vec![],
None,
trace2,
vec![],
)
.await
});
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if ex.running.lock().await.contains_key(&trace) {
break;
}
if Instant::now() >= deadline {
panic!("任务未在预期时间内注册");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(ex.cancel(&trace).await, "应命中运行中任务");
assert!(!ex.cancel(&trace).await, "重复取消不应命中");
let outcome = handle.await.unwrap().unwrap();
assert!(outcome.cancelled);
assert!(outcome.duration_ms < 5000, "取消后应快速返回");
}
#[tokio::test]
async fn cancel_unknown_trace_returns_false() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
assert!(!ex.cancel("never_existed").await);
}
#[tokio::test]
async fn truncates_large_output() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = make_cfg(dir.path().to_path_buf());
cfg.max_output_bytes = 64;
let ex = Executor::new(Arc::new(cfg)).unwrap();
let out = ex
.run(
vec![sh(), "-c".into(), "yes A | head -c 100000".into()],
None,
Some(3000),
None,
vec![],
None,
"trace_trunc".into(),
vec![],
)
.await
.unwrap();
assert!(out.stdout_truncated);
assert_eq!(out.stdout.len(), 64);
}
#[tokio::test]
async fn rejects_path_traversal_workdir() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let err = ex
.run(
vec![sh(), "-c".into(), "echo x".into()],
None,
None,
Some("../../etc".into()),
vec![],
None,
"trace_traversal".into(),
vec![],
)
.await
.unwrap_err();
assert!(matches!(err, ExecError::InvalidArgs(_)), "逃逸应为参数错误");
}
#[tokio::test]
async fn passes_stdin_and_env() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let out = ex
.run(
vec![sh(), "-c".into(), "cat; echo $GREETING".into()],
Some("injected\n".into()),
None,
None,
vec![("GREETING".into(), "hi".into())],
None,
"trace_stdin".into(),
vec![],
)
.await
.unwrap();
assert_eq!(out.exit_code, Some(0));
assert!(out.stdout.contains("injected"));
assert!(out.stdout.contains("hi"));
}
#[tokio::test]
async fn run_stream_emits_stdout_then_done() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let mut rx = ex
.run_stream(
vec![sh(), "-c".into(), "echo hello-stream".into()],
None,
None,
None,
vec![],
None,
"trace_stream".into(),
None,
vec![],
)
.await
.unwrap();
let mut stdout = String::new();
let mut done = false;
while let Some(event) = rx.recv().await {
match event {
StreamEvent::Stdout(b) => stdout.push_str(&String::from_utf8_lossy(&b)),
StreamEvent::Stderr(_) => {}
StreamEvent::Done(o) => {
assert_eq!(o.exit_code, Some(0));
assert!(!o.timeout && !o.cancelled);
done = true;
}
}
}
assert!(done, "应以 Done 结束");
assert!(stdout.contains("hello-stream"));
}
#[tokio::test]
async fn run_stream_reports_timeout() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let mut rx = ex
.run_stream(
vec![sh(), "-c".into(), "sleep 30".into()],
None,
Some(200),
None,
vec![],
None,
"trace_stream_to".into(),
None,
vec![],
)
.await
.unwrap();
let mut timeout = false;
while let Some(event) = rx.recv().await {
if let StreamEvent::Done(o) = event {
assert!(o.timeout);
assert!(o.duration_ms < 5000);
timeout = true;
}
}
assert!(timeout);
}
#[tokio::test]
async fn run_stream_cancels_when_receiver_dropped() {
// 客户端断开 SSE(drop Receiver)后,后台任务应主动取消子进程并从 running 注销,
// 而非让命令跑到自然结束或超时——否则并发槽被已离开的用户长任务占满。
let dir = tempfile::tempdir().unwrap();
let ex = Arc::new(Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap());
let trace = "trace_stream_drop".to_string();
let rx = ex
.run_stream(
vec![sh(), "-c".into(), "sleep 30".into()],
None,
Some(30_000),
None,
vec![],
None,
trace.clone(),
None,
vec![],
)
.await
.unwrap();
// 等待任务注册到 running map(确认它确实在跑)
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if ex.running.lock().await.contains_key(&trace) {
break;
}
if Instant::now() >= deadline {
panic!("任务未在预期时间内注册");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 模拟客户端断开:drop Receiver
drop(rx);
// 给后台自动取消留出时间(远小于 30s,证明不是自然结束/超时)
tokio::time::sleep(Duration::from_millis(500)).await;
// trace_id 应已从 running 注销 → 手动 cancel 必然返回 false
assert!(
!ex.running.lock().await.contains_key(&trace),
"Receiver drop 后任务应已自动从 running 注销"
);
assert!(
!ex.cancel(&trace).await,
"Receiver drop 后任务应已被自动取消,手动 cancel 不应再命中"
);
}
#[tokio::test]
async fn metrics_record_exec_outcome() {
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
ex.run(
vec![sh(), "-c".into(), "echo ok".into()],
None,
None,
None,
vec![],
None,
"trace_metrics".into(),
vec![],
)
.await
.unwrap();
let text = ex.metrics().gather();
assert!(text.contains("sandbox_exec_total"), "应有执行计数指标");
assert!(
text.contains("outcome=\"completed\""),
"应记录 completed 结果"
);
assert!(text.contains("sandbox_exec_duration_seconds"));
assert!(text.contains("sandbox_concurrent_limit"));
}
/// 构造一个 FSIZE 限额(写入字节数上限),用于验证 setrlimit 实际生效。
fn fsize_limit(bytes: u64) -> Vec<EffectiveRlimit> {
vec![EffectiveRlimit {
kind: crate::rlimits::RlimitKind::Fsize,
value: bytes,
}]
}
#[cfg(unix)]
#[tokio::test]
async fn rlimit_fsize_blocks_large_write() {
// FSIZE=4KB 时,写超过限额的文件应被拦(write 触发 SIGXFSZ 或 EFBIG → 进程非零退出)。
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let out = ex
.run(
vec![
sh(),
"-c".into(),
"dd if=/dev/zero of=big.dat bs=1024 count=64 2>/dev/null".into(),
],
None,
Some(5000),
None,
vec![],
None,
"trace_fsize".into(),
fsize_limit(4096),
)
.await
.unwrap();
// dd 写 64KB 远超 4KB 限额 → 被中断,exit_code 非 0 或被信号终止
assert!(
out.exit_code != Some(0),
"FSIZE 限额应阻止大文件写入(exit={:?})",
out.exit_code
);
}
#[tokio::test]
async fn rlimit_fsize_allows_small_write() {
// FSIZE 足够大时,正常写入应成功(验证限额不误杀正常命令)。
let dir = tempfile::tempdir().unwrap();
let ex = Executor::new(Arc::new(make_cfg(dir.path().to_path_buf()))).unwrap();
let out = ex
.run(
vec![
sh(),
"-c".into(),
"echo hello > small.dat && cat small.dat".into(),
],
None,
Some(5000),
None,
vec![],
None,
"trace_fsize_ok".into(),
fsize_limit(1_048_576),
)
.await
.unwrap();
assert_eq!(out.exit_code, Some(0));
assert!(out.stdout.contains("hello"));
}
#[tokio::test]
async fn internal_error_message_is_sanitized() {
// Internal 错误的 Display 只含 user_msg,不含 detail(路径/errno)。
let err = ExecError::Internal {
user_msg: "无法启动进程",
detail: "spawn 失败: No such file or directory (os error 2): /secret/path".into(),
};
let display = format!("{err}");
assert_eq!(display, "无法启动进程");
assert!(
!display.contains("/secret/path"),
"user_msg 不应泄露 detail 路径"
);
assert!(!display.contains("os error"), "user_msg 不应泄露 errno");
}
}