Skip to main content

yggdrasil/api/code_runner/
execute.rs

1//! 代码执行 server functions:StartExec / GetExecResult。
2//!
3//! StartExec 流程:速率限制 → 语言白名单 → 源码大小校验 → 入队(DashMap)
4//! → spawn 后台 task(信号量限并发 + clamp_limits + run_in_container)。
5//! 返回 task_id 供前端轮询。GetExecResult 读取任务条目。
6//!
7//! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部
8//! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。
9//!
10//! 双目标可见性:本模块**不** cfg-gate(server function 需对 WASM 可见以便客户端
11//! 调用),但所有 server-only 的 `use` 与全局静态量都单独 gate,使 WASM 侧仅保留
12//! 函数签名(body 被 server 宏剥离)。与 posts 模块的约定一致。
13
14// 与 posts / settings 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
15#![allow(clippy::unused_unit, deprecated)]
16
17use dioxus::prelude::*;
18
19// 共享数据类型在函数签名中出现,双目标可见,不 gate。
20use crate::api::code_runner::{ExecRequest, ExecTask};
21// ExecResult/ExecStatus 仅在 server function body(被宏剥离到 WASM 之外)内使用。
22#[cfg(feature = "server")]
23use crate::api::code_runner::{ExecResult, ExecStatus};
24
25// server-only 辅助模块与依赖:仅在 server function body(被宏剥离到 WASM 之外)内使用。
26#[cfg(feature = "server")]
27use crate::api::auth::get_current_admin_user;
28#[cfg(feature = "server")]
29use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES};
30#[cfg(feature = "server")]
31use crate::api::code_runner::progress::{
32    gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS,
33    EXEC_TASKS,
34};
35#[cfg(feature = "server")]
36use crate::api::rate_limit::{check_code_exec_limit, get_client_ip};
37#[cfg(feature = "server")]
38use crate::infra::docker::{run_in_container, run_in_container_stream, OutputChunk};
39#[cfg(feature = "server")]
40use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG};
41#[cfg(feature = "server")]
42use std::sync::{Arc, LazyLock};
43#[cfg(feature = "server")]
44use std::time::Duration;
45#[cfg(feature = "server")]
46use tokio::sync::Semaphore;
47
48/// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。
49#[cfg(feature = "server")]
50pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
51    LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));
52
53/// 从 FullstackContext 提取客户端 IP(无上下文时退回 "unknown")。
54#[cfg(feature = "server")]
55fn client_ip() -> String {
56    match dioxus::fullstack::FullstackContext::current() {
57        Some(ctx) => {
58            let parts = ctx.parts_mut();
59            get_client_ip(&parts.headers)
60        }
61        None => "unknown".to_string(),
62    }
63}
64
65/// 公共校验逻辑:速率限制 + 语言白名单 + 源码大小。
66///
67/// [`start_exec`](轮询路径)与 [`start_exec_stream`](SSE 路径)共用,
68/// 保证两条路径的准入约束完全一致。admin 跳过速率限制。
69#[cfg(feature = "server")]
70fn validate_exec_request(req: &ExecRequest) -> Result<(), ServerFnError> {
71    // 1. 语言白名单
72    if !is_supported_lang(&req.language) {
73        return Err(ServerFnError::new("不支持该执行语言".to_string()));
74    }
75
76    // 2. 源码大小限制
77    if req.source.len() > RUNNER_CONFIG.max_source_bytes as usize {
78        return Err(ServerFnError::new("源代码过大".to_string()));
79    }
80
81    Ok(())
82}
83
84/// 速率限制检查(admin 放行)。
85#[cfg(feature = "server")]
86async fn check_rate_limit_for_user() -> Result<(), ServerFnError> {
87    let is_admin = get_current_admin_user().await.is_ok();
88    if !is_admin {
89        let ip = client_ip();
90        if let Err(msg) = check_code_exec_limit(&ip) {
91            return Err(ServerFnError::new(msg));
92        }
93    }
94    Ok(())
95}
96
97/// 后台执行容器任务的核心:信号量限并发 → clamp_limits → 调用 runner → 写 EXEC_TASKS。
98///
99/// [`start_exec`](轮询路径)与 [`start_exec_stream`](SSE 路径)共用本函数,
100/// 唯一的行为分叉是 `stream_tx`:
101/// - `None`:走 [`run_in_container`],输出仅落终态 buffer(轮询路径)。
102/// - `Some(tx)`:走 [`run_in_container_stream`],逐 chunk 推给 SSE channel(流式路径)。
103///
104/// 其余(并发槽获取、资源钳制、状态映射、超时/失败脱敏、日志前缀)两条路径完全一致。
105/// 两个 runner 返回元组仅末尾多一个 `_timed_out`,此处统一裁成 4 元组后续处理。
106#[cfg(feature = "server")]
107fn spawn_exec_task(
108    task_id: String,
109    req: ExecRequest,
110    stream_tx: Option<tokio::sync::mpsc::Sender<OutputChunk>>,
111) {
112    // 归一化为 canonical key(js→node / ts→bun / rs→rust),确保 LANGUAGES.get 命中、
113    // ExecResult.language 回显 canonical(与 markdown 渲染期的 data-lang 一致)。
114    let lang_key = normalize_lang(&req.language);
115    tokio::spawn(async move {
116        let sem = &*RUNNER_SEMAPHORE;
117
118        // 排队等待可用容器槽
119        let ticket = match tokio::time::timeout(
120            Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs),
121            sem.acquire(),
122        )
123        .await
124        {
125            Ok(Ok(t)) => t,
126            _ => {
127                update_task_stage(&task_id, ExecStatus::Failed, "系统繁忙,排队超时");
128                return;
129            }
130        };
131
132        update_task_stage(&task_id, ExecStatus::Running, "启动容器");
133
134        let lang_def = match LANGUAGES.get(&lang_key) {
135            Some(d) => d,
136            None => {
137                // 理论不可达:入口已校验白名单;防御性兜底。
138                update_task_stage(&task_id, ExecStatus::Failed, "语言未注册");
139                return;
140            }
141        };
142
143        // 资源限制合并与钳制
144        let base_limits = req
145            .overrides
146            .unwrap_or_else(|| lang_def.default_limits.clone());
147        let final_limits = clamp_limits(base_limits, lang_def.allow_network);
148
149        let start_time = chrono::Utc::now();
150        // 唯一的行为分叉:流式路径逐 chunk 推 SSE,非流式仅落终态 buffer。
151        let stream_suffix = if stream_tx.is_some() { " (stream)" } else { "" };
152        let res = match stream_tx {
153            Some(tx) => run_in_container_stream(
154                &lang_def.image,
155                &lang_def.run_cmd,
156                &req.source,
157                &lang_def.extension,
158                final_limits,
159                tx,
160            )
161            .await
162            .map(|(exit_code, stdout, stderr, oom_killed, _)| {
163                (exit_code, stdout, stderr, oom_killed)
164            }),
165            None => {
166                run_in_container(
167                    &lang_def.image,
168                    &lang_def.run_cmd,
169                    &req.source,
170                    &lang_def.extension,
171                    final_limits,
172                )
173                .await
174            }
175        };
176        let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64;
177
178        drop(ticket); // 显式释放信号量
179
180        match res {
181            Ok((exit_code, stdout, stderr, oom_killed)) => {
182                let status = if oom_killed {
183                    ExecStatus::OomKilled
184                } else if exit_code == Some(0) {
185                    ExecStatus::Success
186                } else {
187                    ExecStatus::Error
188                };
189                let exec_res = ExecResult {
190                    status: status.clone(),
191                    stdout,
192                    stderr,
193                    exit_code,
194                    duration_ms,
195                    language: lang_key.clone(),
196                };
197                update_task_result(&task_id, status, exec_res);
198            }
199            Err(e) => {
200                // 系统内部异常脱敏:日志记详情,前端只见通用消息。
201                let s = e.to_string();
202                let is_timeout = s.contains("TimedOut");
203                tracing::error!(error = ?e, task_id = %task_id, "container execution failed{}", stream_suffix);
204                let status = if is_timeout {
205                    ExecStatus::Timeout
206                } else {
207                    ExecStatus::Failed
208                };
209                let exec_res = ExecResult {
210                    status: status.clone(),
211                    stdout: String::new(),
212                    stderr: if is_timeout {
213                        "执行超时".to_string()
214                    } else {
215                        "系统暂时不可用".to_string()
216                    },
217                    exit_code: None,
218                    duration_ms,
219                    language: lang_key.clone(),
220                };
221                update_task_result(&task_id, status, exec_res);
222            }
223        }
224    });
225}
226
227/// 提交一次代码执行请求。
228///
229/// 同步校验通过后立即返回 task_id,容器在后台执行;结果通过
230/// [`get_exec_result`] 轮询查询。语言不支持 / 源码过大 / 触发限流时同步返回错误。
231///
232/// admin 角色跳过速率限制(便于作者在沙箱调试),但仍受并发槽、
233/// 资源钳制与源码大小校验约束。
234#[server(StartExec, "/api")]
235pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
236    check_rate_limit_for_user().await?;
237    validate_exec_request(&req)?;
238
239    // 生成任务 ID 并入队
240    let task_id = uuid::Uuid::new_v4().to_string();
241    insert_task(task_id.clone());
242
243    // 顺手回收过期任务(同时清 EXEC_TASKS 和 EXEC_STREAMS)
244    gc_old_tasks();
245
246    // 后台执行:信号量限并发 → clamp_limits → run_in_container
247    // (spawn 逻辑见 spawn_exec_task;stream_tx=None 走非流式 runner)
248    spawn_exec_task(task_id.clone(), req, None);
249
250    Ok(task_id)
251}
252
253/// 提交一次流式代码执行请求。
254///
255/// 校验链与 [`start_exec`] 完全一致(速率限制 + 白名单 + 大小),返回 task_id。
256/// 前端拿到 task_id 后用 EventSource 连 `GET /api/exec/stream?task_id=X`,
257/// SSE handler 从 `EXEC_STREAMS` 取走 receiver 做流式输出。
258///
259/// 后台 spawn 与 `start_exec` 并行写两处:
260/// - `run_in_container_stream` 推 chunk 到 SSE(流式路径)
261/// - `update_task_result` 写 `EXEC_TASKS`(轮询兜底路径,SSE 不可用或 Tiptap 编辑器用)
262#[server(StartExecStream, "/api")]
263pub async fn start_exec_stream(req: ExecRequest) -> Result<String, ServerFnError> {
264    check_rate_limit_for_user().await?;
265    validate_exec_request(&req)?;
266
267    let task_id = uuid::Uuid::new_v4().to_string();
268    // EXEC_TASKS 给轮询兜底路径(get_exec_result / Tiptap 编辑器 run-code 闭包)。
269    insert_task(task_id.clone());
270
271    // EXEC_STREAMS 给 SSE 流式路径:创建 channel,rx 存表等前端取,tx 进后台 task。
272    let (tx, rx) = tokio::sync::mpsc::channel(64);
273    EXEC_STREAMS.insert(
274        task_id.clone(),
275        StreamEntry {
276            rx,
277            created_at: chrono::Utc::now(),
278        },
279    );
280
281    gc_old_tasks();
282
283    // 公共 spawn 逻辑见 spawn_exec_task;stream_tx=Some(tx) 走 run_in_container_stream。
284    spawn_exec_task(task_id.clone(), req, Some(tx));
285
286    Ok(task_id)
287}
288
289/// 查询任务执行结果(前端轮询)。
290#[server(GetExecResult, "/api")]
291pub async fn get_exec_result(task_id: String) -> Result<ExecTask, ServerFnError> {
292    if let Some(task) = EXEC_TASKS.get(&task_id) {
293        Ok(task.clone())
294    } else {
295        Err(ServerFnError::new("找不到指定的任务".to_string()))
296    }
297}
298
299#[cfg(all(test, feature = "server"))]
300mod tests {
301    use super::*;
302
303    fn req(language: &str, source: &str) -> ExecRequest {
304        ExecRequest {
305            language: language.to_string(),
306            source: source.to_string(),
307            overrides: None,
308        }
309    }
310
311    #[test]
312    fn validate_accepts_registered_language() {
313        // python/node/go/rust/bun 默认注册,应放行。
314        for lang in ["python", "node", "go", "rust", "bun"] {
315            assert!(
316                validate_exec_request(&req(lang, "x")).is_ok(),
317                "{lang} 应被支持"
318            );
319        }
320    }
321
322    #[test]
323    fn validate_accepts_aliases() {
324        // 别名经 is_supported_lang 内的 normalize_lang 归一后命中注册表,应放行。
325        // 锁定该契约:前端 CodeRunner 可能带着别名(如历史渲染的 data-lang="js")
326        // 调 StartExec,校验链不能因别名拒绝。
327        for lang in ["js", "javascript", "rs", "ts", "typescript"] {
328            assert!(
329                validate_exec_request(&req(lang, "x")).is_ok(),
330                "别名 {lang} 应被支持"
331            );
332        }
333    }
334
335    #[test]
336    fn validate_language_is_case_and_whitespace_insensitive() {
337        // is_supported_lang 内部 trim+lowercase,校验链应透传该容忍。
338        assert!(validate_exec_request(&req("Python", "x")).is_ok());
339        assert!(validate_exec_request(&req("  RUST  ", "x")).is_ok());
340        assert!(validate_exec_request(&req("Go", "x")).is_ok());
341    }
342
343    #[test]
344    fn validate_rejects_unregistered_language() {
345        // 未注册语言 / 命令注入尝试都应在白名单阶段被拒。
346        assert!(validate_exec_request(&req("c", "x")).is_err());
347        assert!(validate_exec_request(&req("bash", "x")).is_err());
348        assert!(validate_exec_request(&req("python2", "x")).is_err());
349        assert!(validate_exec_request(&req("", "x")).is_err());
350    }
351
352    #[test]
353    fn validate_rejects_multi_token_language() {
354        // 语言字段不会到达容器层:只有命中 LANGUAGES 的 key 才放行,
355        // 否则被拒。任何多 token / 含 shell 元字符的值都因找不到注册项而拒绝。
356        assert!(validate_exec_request(&req("python; rm -rf /", "x")).is_err());
357        assert!(validate_exec_request(&req("python node", "x")).is_err());
358        assert!(validate_exec_request(&req("python$(whoami)", "x")).is_err());
359    }
360
361    #[test]
362    fn validate_language_tolerates_surrounding_whitespace() {
363        // is_supported_lang 内部 trim+lowercase:首尾空白(含换行)被吃掉后等于 "python"。
364        // 锁定该契约——语言字段只用于 HashMap 查 key,空白无害。
365        assert!(validate_exec_request(&req("python\n", "x")).is_ok());
366        assert!(validate_exec_request(&req("\tpython\t", "x")).is_ok());
367    }
368
369    #[test]
370    fn validate_rejects_source_exceeding_max_bytes() {
371        let max = RUNNER_CONFIG.max_source_bytes as usize;
372        // 恰好 max 放行,max+1 拒绝——边界精确,防止 off-by-one 让攻击者多塞 1 字节。
373        let exactly = "a".repeat(max);
374        assert!(
375            validate_exec_request(&req("python", &exactly)).is_ok(),
376            "源码恰好等于上限应放行"
377        );
378        let over = "a".repeat(max + 1);
379        let err = validate_exec_request(&req("python", &over)).expect_err("超出上限应拒绝");
380        assert!(
381            err.to_string().contains("过大"),
382            "错误信息应提及大小: {err}"
383        );
384    }
385
386    #[test]
387    fn validate_empty_source_accepted_for_supported_lang() {
388        // 空源码不是校验层的职责(容器层处理),这里只验语言/大小。
389        // 锁定该契约:空源码 + 合法语言 → Ok,避免未来误把空当拒绝。
390        assert!(validate_exec_request(&req("python", "")).is_ok());
391    }
392
393    #[test]
394    fn validate_checks_language_before_size() {
395        // 不支持语言 + 巨大源码:应先因语言被拒(而非先报大小)。
396        let huge = "a".repeat((RUNNER_CONFIG.max_source_bytes as usize) + 100);
397        let err = validate_exec_request(&req("brainfuck", &huge)).unwrap_err();
398        assert!(err.to_string().contains("语言"), "应先报语言错误: {err}");
399    }
400}