//! 沙箱执行内核:以受限子进程方式运行外部命令,提供超时、取消、流式输出与进程组清理。 //! //! 当前阶段隔离措施(跨 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>, } /// 已 spawn 的子进程及其管道、取消信号与临时工作目录持有者。 struct SpawnedChild { child: tokio::process::Child, pid: Option, stdout: tokio::process::ChildStdout, stderr: tokio::process::ChildStderr, cancel_rx: oneshot::Receiver<()>, start: Instant, /// 临时工作目录:持有到子进程退出后才回收(None 表示用 base 内持久子目录)。 _temp_keep: Option, } /// 流式执行事件:stdout / stderr 增量,或执行结束的汇总。 pub enum StreamEvent { Stdout(Vec), Stderr(Vec), Done(ExecOutcome), } #[derive(Clone, Copy)] enum OutputKind { Stdout, Stderr, } pub struct Executor { config: Arc, base_canon: PathBuf, /// 运行中任务注册表:trace_id → 句柄。cancel 端点据此定位并 SIGKILL 进程组。 running: Arc>>, metrics: Arc, } /// 单次执行的产出。 #[derive(Debug)] pub struct ExecOutcome { pub exit_code: Option, 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) -> std::io::Result { 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 { &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, stdin: Option, workdir: Option, env: Vec<(String, String)>, trace_id: String, effective: Vec, ) -> Result { 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, stdin: Option, timeout_ms: Option, workdir: Option, env: Vec<(String, String)>, max_output_bytes: Option, trace_id: String, effective: Vec, ) -> Result { 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 = 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, stdin: Option, timeout_ms: Option, workdir: Option, env: Vec<(String, String)>, max_output_bytes: Option, trace_id: String, permit: Option, effective: Vec, ) -> Result, 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::(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 = 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, stdin: Option, timeout_ms: Option, workdir: Option, env: Vec<(String, String)>, max_output_bytes: Option, trace_id: String, effective: Vec, isolation_mode: &str, warm_pool: Option>, allow_fallback: bool, ) -> Result { 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, argv: &[String], stdin: &Option, timeout_ms: Option, env: &[(String, String)], max_output_bytes: Option, trace_id: &str, ) -> Result { 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::>) .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, stdin: Option, timeout_ms: Option, workdir: Option, env: Vec<(String, String)>, max_output_bytes: Option, trace_id: String, effective: Vec, isolation_mode: &str, warm_pool: Option>, permit: Option, allow_fallback: bool, ) -> Result, 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, argv: &[String], stdin: &Option, timeout_ms: Option, env: &[(String, String)], max_output_bytes: Option, _trace_id: &str, ) -> Result, 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(reader: R, max: usize) -> (Vec, 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( reader: R, max: usize, kind: OutputKind, tx: mpsc::Sender, ) -> 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 { 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"); } }