Compare commits
5 Commits
460088fa43
...
eaabb87108
| Author | SHA1 | Date | |
|---|---|---|---|
| eaabb87108 | |||
| ca676a6146 | |||
| 52e5ee2736 | |||
| 864c813c6d | |||
| 22d226d4e8 |
@ -117,7 +117,7 @@ docker buildx build --platform linux/amd64 --load -t localhost/yggdrasil:latest
|
||||
- 首次约 15-30 分钟(Rosetta 下 cargo 全量编译),有 buildkit 缓存后分钟级
|
||||
- 产物 `localhost/yggdrasil:latest`,scratch 运行时层约 16MB
|
||||
|
||||
### 5 个 Code Runner 沙箱镜像
|
||||
### 6 个 Code Runner 沙箱镜像
|
||||
|
||||
runner Dockerfile `FROM yggdrasil-runner-base:latest`(无 `localhost/` 前缀),必须**先建 base 再建子镜像**:
|
||||
|
||||
@ -127,8 +127,8 @@ docker buildx build --platform linux/amd64 --load \
|
||||
-t localhost/yggdrasil-runner-base:latest docker/runner-base
|
||||
# 2. 再 tag 无前缀名,让子镜像 FROM 能解析
|
||||
docker tag localhost/yggdrasil-runner-base:latest yggdrasil-runner-base:latest
|
||||
# 3. 4 个子镜像(它们 FROM yggdrasil-runner-base:latest)
|
||||
for img in python node go rust; do
|
||||
# 3. 5 个子镜像(它们 FROM yggdrasil-runner-base:latest)
|
||||
for img in python node go rust bun; do
|
||||
docker buildx build --platform linux/amd64 --load \
|
||||
-t localhost/yggdrasil-runner-$img:latest docker/runner-$img
|
||||
docker tag localhost/yggdrasil-runner-$img:latest yggdrasil-runner-$img:latest
|
||||
@ -140,7 +140,7 @@ done
|
||||
### 构建验证
|
||||
|
||||
```bash
|
||||
for img in yggdrasil yggdrasil-runner-base yggdrasil-runner-python yggdrasil-runner-node yggdrasil-runner-go yggdrasil-runner-rust; do
|
||||
for img in yggdrasil yggdrasil-runner-base yggdrasil-runner-python yggdrasil-runner-node yggdrasil-runner-go yggdrasil-runner-rust yggdrasil-runner-bun; do
|
||||
docker image inspect localhost/$img:latest --format "$img: {{.Architecture}} manifests={{.Manifests}}"
|
||||
done
|
||||
# 期望: 每行 amd64 且 manifests=[](单平台,非 manifest list)
|
||||
@ -157,6 +157,7 @@ docker save \
|
||||
localhost/yggdrasil-runner-node:latest \
|
||||
localhost/yggdrasil-runner-go:latest \
|
||||
localhost/yggdrasil-runner-rust:latest \
|
||||
localhost/yggdrasil-runner-bun:latest \
|
||||
-o /tmp/yggdrasil-runners.tar
|
||||
gzip -f /tmp/yggdrasil-app.tar /tmp/yggdrasil-runners.tar
|
||||
|
||||
@ -184,6 +185,7 @@ ssh <host> 'docker tag localhost/yggdrasil-runner-python:latest yggdrasil-runner
|
||||
ssh <host> 'docker tag localhost/yggdrasil-runner-node:latest yggdrasil-runner-node:latest'
|
||||
ssh <host> 'docker tag localhost/yggdrasil-runner-go:latest yggdrasil-runner-go:latest'
|
||||
ssh <host> 'docker tag localhost/yggdrasil-runner-rust:latest yggdrasil-runner-rust:latest'
|
||||
ssh <host> 'docker tag localhost/yggdrasil-runner-bun:latest yggdrasil-runner-bun:latest'
|
||||
```
|
||||
|
||||
验证短名可解析:
|
||||
|
||||
@ -181,14 +181,14 @@ src/infra/ — Docker execution layer + runner config (server-only); see
|
||||
Readers can execute fenced code blocks in isolated Docker containers; authors get a `/admin/runner` trial sandbox. Three-layer architecture, all Docker interaction gated behind `#[cfg(feature = "server")]`:
|
||||
|
||||
- **Execution layer** (`src/infra/docker.rs`, server-only): `bollard` client over Unix socket (`DOCKER_CLIENT` LazyLock). `run_in_container` creates a read-only-rootfs container (tmpfs `/code`,`/tmp`,`/run`; cpu/memory/pids/ulimits cap; `cap_drop=ALL`; `no-new-privileges`; non-root `1000:1000`; `network_mode` none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to `output_bytes`), inspects OOM, force-removes via `ContainerGuard` Drop. `src/infra/runner_config.rs` (`RUNNER_CONFIG`) reads all `CODE_RUNNER_*` env vars; `clamp_limits` AND-merges request overrides × language `allow_network` × global switch.
|
||||
- **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info`; `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
|
||||
- **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info` + `normalize_lang` (alias normalization: `js`/`javascript`→`node`, `rs`→`rust`, `ts`/`typescript`→`bun` — done in `parse_fence_info` so `data-lang`/`ExecRequest.language`/`LANGUAGES.get` only ever see canonical keys `python`/`node`/`go`/`rust`/`bun`); `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). `start_exec`/`start_exec_stream` call `normalize_lang(&req.language)` before `LANGUAGES.get` so aliases survive the wire. System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
|
||||
- **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`. Output is rendered into an xterm.js terminal instance (`src/xterm_bridge.rs` + `libs/xterm-terminal`, mirroring the codemirror bridge pattern) — stdout/stderr streamed via SSE, with full-buffer `writeAll` as a polling fallback.
|
||||
|
||||
**Critical WASM-visibility rule**: `code_runner/execute.rs` is **not** cfg-gated (server functions must be visible to the client), but every server-only `use`/static inside it is individually `#[cfg(feature = "server")]`. The shared `use` of `ExecRequest`/`ExecTask` (used in signatures) stays ungated. `code_runner/languages.rs` and `code_runner/progress.rs` (pure server helpers) are wholly gated. Mirrors the `posts/` module convention.
|
||||
|
||||
**Governor 0.8 caveat**: `Quota::per_day` does not exist; `CODE_EXEC_DAILY_LIMITER` uses `Quota::with_period(24h).allow_burst(daily)`. `RATE_LIMIT_CODE_EXEC_PER_SEC` must be an integer (governor's `per_second` takes `NonZeroU32`; decimals in `.env` fall back to the default 1).
|
||||
|
||||
**Runner images** (`docker/`): `build-runners.sh` builds `yggdrasil-runner-base` → `yggdrasil-runner-python` → `yggdrasil-runner-node` → `yggdrasil-runner-go` → `yggdrasil-runner-rust`; tags must match `LANGUAGES` image fields. Python image symlinks `python`→`python3` to match `run_cmd`. Go image redirects `GOCACHE`/`GOTMPDIR`/`GOPATH` to `/tmp` (read-only rootfs makes `$HOME/.cache` unwritable). Rust image ships a `/usr/local/bin/run-rust.sh` wrapper because `rustc` compile + run is two steps and `docker.rs` injects `run_cmd` via `exec` — `exec A && B` would replace the shell and never reach `B`, so the two steps are encapsulated in the wrapper. `runner.toml` files are image self-descriptions only — runtime config is the Rust `LANGUAGES` registry + `CODE_RUNNER_*` env, not parsed from toml.
|
||||
**Runner images** (`docker/`): `build-runners.sh` builds `yggdrasil-runner-base` → `yggdrasil-runner-python` → `yggdrasil-runner-node` → `yggdrasil-runner-go` → `yggdrasil-runner-rust` → `yggdrasil-runner-bun`; tags must match `LANGUAGES` image fields. Python image symlinks `python`→`python3` to match `run_cmd`. Go image redirects `GOCACHE`/`GOTMPDIR`/`GOPATH` to `/tmp` (read-only rootfs makes `$HOME/.cache` unwritable). Rust image ships a `/usr/local/bin/run-rust.sh` wrapper because `rustc` compile + run is two steps and `docker.rs` injects `run_cmd` via `exec` — `exec A && B` would replace the shell and never reach `B`, so the two steps are encapsulated in the wrapper. Bun image uses the official `bun.sh/install` script (bun isn't in Alpine 3.18 repos; the script auto-detects `/etc/alpine-release` and picks the musl variant) and must `apk add libstdc++ libgcc` — bun's musl binary is C++ and needs the C++ runtime that musl libc alone doesn't provide; `BUN_INSTALL=/opt/bun` is `export`-ed so the pipe `curl | bash` reads it on both sides (a bare `VAR=x curl | bash` only sets it for curl, making bash fall back to `$HOME/.bun`). `runner.toml` files are image self-descriptions only — runtime config is the Rust `LANGUAGES` registry + `CODE_RUNNER_*` env, not parsed from toml.
|
||||
|
||||
## Frontend Lib Subprojects
|
||||
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
#!/bin/sh
|
||||
# 构建代码运行器所需的沙箱镜像:base → python / node / go / rust。
|
||||
# 构建代码运行器所需的沙箱镜像:base → python / node / go / rust / bun。
|
||||
#
|
||||
# 依赖:本机已安装 docker。构建顺序固定(python/node/go/rust 均为 FROM base)。
|
||||
# 依赖:本机已安装 docker。构建顺序固定(python/node/go/rust/bun 均为 FROM base)。
|
||||
# 镜像 tag 与 src/api/code_runner/languages.rs 的注册项严格对应:
|
||||
# yggdrasil-runner-base:latest
|
||||
# yggdrasil-runner-python:latest
|
||||
# yggdrasil-runner-node:latest
|
||||
# yggdrasil-runner-go:latest
|
||||
# yggdrasil-runner-rust:latest
|
||||
# yggdrasil-runner-bun:latest
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
@ -27,6 +28,9 @@ docker build -t yggdrasil-runner-go:latest "$SCRIPT_DIR/runner-go"
|
||||
echo "==> Building yggdrasil-runner-rust:latest"
|
||||
docker build -t yggdrasil-runner-rust:latest "$SCRIPT_DIR/runner-rust"
|
||||
|
||||
echo "==> Building yggdrasil-runner-bun:latest"
|
||||
docker build -t yggdrasil-runner-bun:latest "$SCRIPT_DIR/runner-bun"
|
||||
|
||||
echo "==> Done. Images:"
|
||||
docker images --filter "reference=yggdrasil-runner-*" \
|
||||
--format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
|
||||
|
||||
25
docker/runner-bun/Dockerfile
Normal file
25
docker/runner-bun/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
# Bun 运行沙箱镜像。
|
||||
# 与 src/api/code_runner/languages.rs 的 bun 注册项对齐:
|
||||
# image: yggdrasil-runner-bun:latest
|
||||
# run_cmd: bun run /code/main.ts
|
||||
#
|
||||
# bun 在 Alpine 3.18 的官方仓库里没有,用 bun.sh 官方安装脚本(多架构、
|
||||
# 自动检测 /etc/alpine-release 选 musl 变体)。bun 的 musl 二进制是 C++ 写的,
|
||||
# 运行时依赖 libstdc++ + libgcc(musl 只带 libc,不带 C++ 运行时),必须保留。
|
||||
# 安装期另需 unzip + bash + curl;安装后卸载 unzip/curl 减小镜像。
|
||||
FROM yggdrasil-runner-base:latest
|
||||
|
||||
# libstdc++ / libgcc 是 bun 运行时依赖,不能删;curl/unzip/bash 仅供安装期。
|
||||
# BUN_INSTALL 用 export 对管道两侧生效——「VAR=x cmd | bash」只给左侧设变量,
|
||||
# bash 侧读不到会 fallback 到 $HOME/.bun。指定到 /opt/bun 避免依赖 $HOME
|
||||
# (runner 用户 $HOME 可能不可写或不存在)。
|
||||
RUN apk add --no-cache curl unzip bash libstdc++ libgcc \
|
||||
&& export BUN_INSTALL=/opt/bun \
|
||||
&& curl -fsSL https://bun.sh/install | bash \
|
||||
&& ln -s /opt/bun/bin/bun /usr/local/bin/bun \
|
||||
&& apk del curl unzip \
|
||||
&& bun --version
|
||||
|
||||
COPY runner.toml /runner.toml
|
||||
|
||||
USER runner
|
||||
15
docker/runner-bun/runner.toml
Normal file
15
docker/runner-bun/runner.toml
Normal file
@ -0,0 +1,15 @@
|
||||
# Bun runner 镜像的运行时默认值(参考用)。
|
||||
# 实际生效配置由 Rust 侧 LANGUAGES 注册表 + CODE_RUNNER_* 环境变量决定(见
|
||||
# src/api/code_runner/languages.rs、src/infra/runner_config.rs),本文件仅作为
|
||||
# 镜像构建产物的自描述,便于运维核对镜像与代码的契约一致。
|
||||
language = "bun"
|
||||
extension = "ts"
|
||||
run_command = "bun run /code/main.ts"
|
||||
allow_network = false
|
||||
|
||||
[default_limits]
|
||||
cpu_cores = 1.0
|
||||
memory_mb = 256
|
||||
timeout_secs = 5
|
||||
output_bytes = 1048576
|
||||
allow_network = false
|
||||
@ -107,9 +107,14 @@ export class EditorOptions {
|
||||
/**
|
||||
* 把语言标识映射成 CodeMirror 语言 Extension。
|
||||
* - `python` → @codemirror/lang-python
|
||||
* - `node` / `javascript` / `js` → @codemirror/lang-javascript
|
||||
* - `node` / `javascript` / `js` → @codemirror/lang-javascript(JS 模式)
|
||||
* - `bun` / `typescript` / `ts` → @codemirror/lang-javascript(TypeScript 模式)
|
||||
* - `sql` / 缺省 → @codemirror/lang-sql(PostgreSQL 方言 + schema 补全)
|
||||
*
|
||||
* 归一化后(src/api/code_runner/languages.rs::normalize_lang)CodeRunner 只会
|
||||
* 传来 canonical key(node/bun/python/go/rust),但这里仍保留别名分支作防御——
|
||||
* CodeMirror 也用在 SQL 控制台等场景,调用方可能直接传别名。
|
||||
*
|
||||
* SQL 分支使用 schema 补全;非 SQL 语言忽略 schema(补全仅对 SQL 有意义)。
|
||||
*/
|
||||
function buildLanguageExtension(lang: string, schema: SqlSchema): Extension {
|
||||
@ -120,6 +125,10 @@ function buildLanguageExtension(lang: string, schema: SqlSchema): Extension {
|
||||
if (normalized === 'node' || normalized === 'javascript' || normalized === 'js') {
|
||||
return javascript();
|
||||
}
|
||||
if (normalized === 'bun' || normalized === 'typescript' || normalized === 'ts') {
|
||||
// bun 跑 TypeScript,编辑器用 TS 模式获得类型感知高亮(接口/类型注解等)。
|
||||
return javascript({ typescript: true });
|
||||
}
|
||||
// sql / 缺省:保留原有 PostgreSQL + schema 补全行为
|
||||
return sql({
|
||||
dialect: PostgreSQL,
|
||||
|
||||
@ -26,7 +26,7 @@ use crate::api::code_runner::{ExecResult, ExecStatus};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES};
|
||||
use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::code_runner::progress::{
|
||||
gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS,
|
||||
@ -115,7 +115,9 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
|
||||
|
||||
// 后台执行:信号量限并发 → clamp_limits → run_in_container
|
||||
let task_id_clone = task_id.clone();
|
||||
let lang_key = req.language.clone();
|
||||
// 归一化为 canonical key(js→node / ts→bun / rs→rust),确保 LANGUAGES.get 命中、
|
||||
// ExecResult.language 回显 canonical(与 markdown 渲染期的 data-lang 一致)。
|
||||
let lang_key = normalize_lang(&req.language);
|
||||
tokio::spawn(async move {
|
||||
let sem = &*RUNNER_SEMAPHORE;
|
||||
|
||||
@ -243,7 +245,8 @@ pub async fn start_exec_stream(req: ExecRequest) -> Result<String, ServerFnError
|
||||
gc_old_tasks();
|
||||
|
||||
let task_id_clone = task_id.clone();
|
||||
let lang_key = req.language.clone();
|
||||
// 同 start_exec:归一化别名,LANGUAGES.get 用 canonical key。
|
||||
let lang_key = normalize_lang(&req.language);
|
||||
tokio::spawn(async move {
|
||||
let sem = &*RUNNER_SEMAPHORE;
|
||||
|
||||
@ -361,8 +364,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_registered_language() {
|
||||
// python/node/go/rust 默认注册,应放行。
|
||||
for lang in ["python", "node", "go", "rust"] {
|
||||
// python/node/go/rust/bun 默认注册,应放行。
|
||||
for lang in ["python", "node", "go", "rust", "bun"] {
|
||||
assert!(
|
||||
validate_exec_request(&req(lang, "x")).is_ok(),
|
||||
"{lang} 应被支持"
|
||||
@ -370,6 +373,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_aliases() {
|
||||
// 别名经 is_supported_lang 内的 normalize_lang 归一后命中注册表,应放行。
|
||||
// 锁定该契约:前端 CodeRunner 可能带着别名(如历史渲染的 data-lang="js")
|
||||
// 调 StartExec,校验链不能因别名拒绝。
|
||||
for lang in ["js", "javascript", "rs", "ts", "typescript"] {
|
||||
assert!(
|
||||
validate_exec_request(&req(lang, "x")).is_ok(),
|
||||
"别名 {lang} 应被支持"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_language_is_case_and_whitespace_insensitive() {
|
||||
// is_supported_lang 内部 trim+lowercase,校验链应透传该容忍。
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
//!
|
||||
//! 实际可用语言默认即注册表里的全部;若设置了 `CODE_RUNNER_LANGUAGES`
|
||||
//! 环境变量,则进一步收窄到该白名单内([`is_supported_lang`])。
|
||||
//!
|
||||
//! **别名归一化**:[`normalize_lang`] 把 `js`/`javascript` 归一为 `node`、
|
||||
//! `rs` 归一为 `rust`、`ts`/`typescript` 归一为 `bun`。归一化在 [`parse_fence_info`]
|
||||
//! 与 [`is_supported_lang`] 内完成,markdown 渲染期就把别名换成 canonical key,
|
||||
//! 下游 `data-lang` / `ExecRequest.language` / `LANGUAGES.get` 只见到 canonical
|
||||
//! 名(`python`/`node`/`go`/`rust`/`bun`),不再出现别名,避免查表落空。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
@ -107,32 +113,86 @@ pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = LazyLock::new(||
|
||||
},
|
||||
);
|
||||
|
||||
// bun:原生 TypeScript 运行器(无需 tsc 预编译)。`ts`/`typescript` 别名
|
||||
// 归一到 `bun`(见 LANG_ALIASES)。bun 镜像用官方安装脚本装在 alpine 上,
|
||||
// 单步 `bun run /code/main.ts` 直接执行,无需 wrapper。
|
||||
// 默认资源与 node 对齐:JS 运行时开销相近,256MB/5s 足够。
|
||||
m.insert(
|
||||
"bun".to_string(),
|
||||
LanguageDef {
|
||||
image: "yggdrasil-runner-bun:latest".to_string(),
|
||||
run_cmd: "bun run /code/main.ts".to_string(),
|
||||
extension: "ts".to_string(),
|
||||
default_limits: ResourceLimits {
|
||||
cpu_cores: 1.0,
|
||||
memory_mb: 256,
|
||||
timeout_secs: 5,
|
||||
output_bytes: 1_048_576,
|
||||
allow_network: false,
|
||||
},
|
||||
allow_network: false,
|
||||
},
|
||||
);
|
||||
|
||||
m
|
||||
});
|
||||
|
||||
/// 是否支持该语言:必须在 LANGUAGES 注册表中存在。
|
||||
/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言);
|
||||
/// 未设置则注册表里的语言全部放行。
|
||||
pub fn is_supported_lang(lang: &str) -> bool {
|
||||
/// 语言别名 → canonical key 映射。
|
||||
///
|
||||
/// canonical key(`python`/`node`/`go`/`rust`/`bun`)本身不在表里——它们原样
|
||||
/// 通过。仅收录读者/作者习惯的简写:`js`/`javascript`→`node`、`rs`→`rust`、
|
||||
/// `ts`/`typescript`→`bun`。比较大小写不敏感(见 [`normalize_lang`])。
|
||||
const LANG_ALIASES: &[(&str, &str)] = &[
|
||||
("js", "node"),
|
||||
("javascript", "node"),
|
||||
("rs", "rust"),
|
||||
("ts", "bun"),
|
||||
("typescript", "bun"),
|
||||
];
|
||||
|
||||
/// 把语言标识归一化为 `LANGUAGES` 注册表里的 canonical key。
|
||||
///
|
||||
/// 步骤:trim → lowercase → 查 [`LANG_ALIASES`],命中返回映射值,未命中返回
|
||||
/// trim+lowercase 后的原值(保证 canonical 名与未注册字符串都能被调用方处理)。
|
||||
///
|
||||
/// 归一化是 [`is_supported_lang`] / [`parse_fence_info`] 的前置步骤,也是
|
||||
/// `execute.rs` 在 `LANGUAGES.get` 前必须调用的——否则用别名查表会落空。
|
||||
pub fn normalize_lang(lang: &str) -> String {
|
||||
let clean = lang.trim().to_lowercase();
|
||||
LANGUAGES.contains_key(&clean)
|
||||
for &(from, to) in LANG_ALIASES {
|
||||
if clean == from {
|
||||
return to.to_string();
|
||||
}
|
||||
}
|
||||
clean
|
||||
}
|
||||
|
||||
/// 是否支持该语言:先 [`normalize_lang`] 归一化,再查 `LANGUAGES` 注册表。
|
||||
/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言,
|
||||
/// 白名单用 canonical key 比较——运维写 `node` 而非 `js`);未设置则全部放行。
|
||||
pub fn is_supported_lang(lang: &str) -> bool {
|
||||
let canonical = normalize_lang(lang);
|
||||
LANGUAGES.contains_key(&canonical)
|
||||
&& RUNNER_CONFIG
|
||||
.languages
|
||||
.as_ref()
|
||||
.is_none_or(|list| list.iter().any(|l| l == &clean))
|
||||
.is_none_or(|list| list.iter().any(|l| l == &canonical))
|
||||
}
|
||||
|
||||
/// 解析围栏代码块的 info string。
|
||||
///
|
||||
/// 格式:`<lang> [runnable|run] [ {<ResourceLimits JSON>} ]`
|
||||
///
|
||||
/// 返回 `(lang, runnable, overrides)`。未知 token 静默忽略;JSON 解析失败时 overrides 为 None。
|
||||
/// 返回 `(lang, runnable, overrides)`。`lang` 已经过 [`normalize_lang`] 归一化
|
||||
/// (`js`→`node`、`ts`→`bun` 等),下游 markdown 渲染 / ExecRequest.language /
|
||||
/// `LANGUAGES.get` 都拿到 canonical key。未知 token 静默忽略;JSON 解析失败时
|
||||
/// overrides 为 None。
|
||||
pub fn parse_fence_info(info: &str) -> (String, bool, Option<ResourceLimits>) {
|
||||
let tokens: Vec<&str> = info.split_whitespace().collect();
|
||||
if tokens.is_empty() {
|
||||
return ("".to_string(), false, None);
|
||||
}
|
||||
let lang = tokens[0].trim().to_lowercase();
|
||||
let lang = normalize_lang(tokens[0]);
|
||||
let mut runnable = false;
|
||||
let mut overrides = None;
|
||||
|
||||
@ -229,4 +289,87 @@ mod tests {
|
||||
assert!(is_supported_lang(" Python "));
|
||||
assert!(is_supported_lang("NODE"));
|
||||
}
|
||||
|
||||
// ---- 别名归一化(normalize_lang / parse_fence_info / is_supported_lang 三处协同)----
|
||||
|
||||
#[test]
|
||||
fn normalize_lang_canonical_passthrough() {
|
||||
// canonical key 原样通过(仅 trim+lowercase)。
|
||||
assert_eq!(normalize_lang("python"), "python");
|
||||
assert_eq!(normalize_lang("node"), "node");
|
||||
assert_eq!(normalize_lang("go"), "go");
|
||||
assert_eq!(normalize_lang("rust"), "rust");
|
||||
assert_eq!(normalize_lang("bun"), "bun");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_lang_aliases() {
|
||||
// 别名 → canonical。
|
||||
assert_eq!(normalize_lang("js"), "node");
|
||||
assert_eq!(normalize_lang("javascript"), "node");
|
||||
assert_eq!(normalize_lang("rs"), "rust");
|
||||
assert_eq!(normalize_lang("ts"), "bun");
|
||||
assert_eq!(normalize_lang("typescript"), "bun");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_lang_case_and_whitespace_insensitive() {
|
||||
// 输入容忍:大小写、首尾空白。匹配 is_supported_lang 的既有契约。
|
||||
assert_eq!(normalize_lang(" JS "), "node");
|
||||
assert_eq!(normalize_lang("JavaScript"), "node");
|
||||
assert_eq!(normalize_lang("\tRS\t"), "rust");
|
||||
assert_eq!(normalize_lang("TypeScript"), "bun");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_lang_unregistered_passthrough() {
|
||||
// 未在别名表、未在注册表的语言原样返回(trim+lowercase 后),
|
||||
// 由调用方决定是否拒绝。normalize_lang 不做存在性判断。
|
||||
assert_eq!(normalize_lang("ruby"), "ruby");
|
||||
assert_eq!(normalize_lang("Brainfuck"), "brainfuck");
|
||||
assert_eq!(normalize_lang(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_supported_lang_accepts_aliases() {
|
||||
// 别名经 normalize_lang 归一后命中注册表,应放行。
|
||||
assert!(is_supported_lang("js"));
|
||||
assert!(is_supported_lang("javascript"));
|
||||
assert!(is_supported_lang("rs"));
|
||||
assert!(is_supported_lang("ts"));
|
||||
assert!(is_supported_lang("typescript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_supported_lang_accepts_bun_canonical() {
|
||||
// bun 作为新增 canonical key,默认放行。
|
||||
assert!(is_supported_lang("bun"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fence_info_normalizes_alias_to_canonical() {
|
||||
// 关键契约:parse_fence_info 返回的 lang 是 canonical key,
|
||||
// 下游 markdown.rs 的 data-lang / class="language-{lang}" 直接用此值。
|
||||
let (lang, runnable, _) = parse_fence_info("js runnable");
|
||||
assert_eq!(lang, "node");
|
||||
assert!(runnable);
|
||||
|
||||
let (lang, _, _) = parse_fence_info("typescript runnable");
|
||||
assert_eq!(lang, "bun");
|
||||
|
||||
let (lang, _, _) = parse_fence_info("rs runnable");
|
||||
assert_eq!(lang, "rust");
|
||||
|
||||
// 大小写不敏感。
|
||||
let (lang, _, _) = parse_fence_info("JavaScript runnable");
|
||||
assert_eq!(lang, "node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fence_info_bun_canonical_runs_as_ts() {
|
||||
// bun 自身是 canonical,runnable 块以 bun 执行。
|
||||
let (lang, runnable, _) = parse_fence_info("bun runnable");
|
||||
assert_eq!(lang, "bun");
|
||||
assert!(runnable);
|
||||
}
|
||||
}
|
||||
|
||||
@ -950,6 +950,47 @@ console.log(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_runnable_block_alias_normalized_to_canonical() {
|
||||
// 关键契约:parse_fence_info 在 markdown 渲染期就把别名归一为 canonical key,
|
||||
// 故 `js runnable` 渲染出的 data-lang 是 "node"(而非 "js")。
|
||||
// 这保证阅读器 CodeRunner 拿到的 language 是 canonical,StartExec 的
|
||||
// LANGUAGES.get 能直接命中,无需前端再做别名映射。
|
||||
let cases = [
|
||||
("js", "node"),
|
||||
("javascript", "node"),
|
||||
("rs", "rust"),
|
||||
("ts", "bun"),
|
||||
("typescript", "bun"),
|
||||
// 大小写不敏感。
|
||||
("JavaScript", "node"),
|
||||
("TypeScript", "bun"),
|
||||
];
|
||||
for (alias, canonical) in cases {
|
||||
let src = format!("```{alias} runnable\nconsole.log(1)\n```");
|
||||
let result = render_markdown_enhanced(&src);
|
||||
assert!(
|
||||
result.html.contains(&format!(r#"data-lang="{canonical}""#)),
|
||||
"别名 {alias} 应归一为 data-lang={canonical}, got: {}",
|
||||
result.html
|
||||
);
|
||||
// 不应残留原始别名作为 data-lang。
|
||||
let bad = format!(r#"data-lang="{alias}""#);
|
||||
assert!(
|
||||
!result.html.contains(&bad),
|
||||
"data-lang 不应保留别名 {alias}, got: {}",
|
||||
result.html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_runnable_block_bun_canonical() {
|
||||
// bun 自身是 canonical(不是别名),runnable 块以 bun 执行。
|
||||
let result = render_markdown_enhanced("```bun runnable\nconsole.log('hi')\n```");
|
||||
assert!(result.html.contains(r#"data-lang="bun""#), "got: {}", result.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
|
||||
// 语言不在白名单(rust 已在白名单,改用 ruby):runnable 标记被忽略,输出普通代码块。
|
||||
|
||||
@ -85,6 +85,8 @@ pub mod server {
|
||||
("js", "js"),
|
||||
("javascript", "js"),
|
||||
("typescript", "ts"),
|
||||
// bun 运行器跑的是 TypeScript,归一化后用 ts 语法高亮。
|
||||
("bun", "ts"),
|
||||
("py", "py"),
|
||||
("python", "py"),
|
||||
("rb", "rb"),
|
||||
@ -486,4 +488,21 @@ const message = ref('Hello Vue!')
|
||||
// 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
|
||||
assert_eq!(by_alias, by_upper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_code_resolves_bun_alias_to_typescript() {
|
||||
// bun 运行器跑 TypeScript;别名表把 "bun" 归一为 "ts" 扩展名。
|
||||
// 用类型注解(TS 特有语法)验证命中的是 TypeScript 语法而非纯 JS。
|
||||
let code = "const x: number = 1;";
|
||||
let result = highlight_code(code, Some("bun"));
|
||||
// 不应回退纯文本(纯文本无 <span> 高亮 span)。
|
||||
assert!(
|
||||
result.contains("<span"),
|
||||
"bun 别名应触发语法高亮, got: {}",
|
||||
result
|
||||
);
|
||||
// 与直接传 ts 的输出一致——别名表正确归一。
|
||||
let by_ts = highlight_code(code, Some("ts"));
|
||||
assert_eq!(result, by_ts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,9 @@ use crate::components::ui::{ADMIN_CARD_CLASS, BTN_PRIMARY_SM};
|
||||
use crate::infra::runner_config::ResourceLimits;
|
||||
|
||||
/// 受支持的语言集合(与 LANGUAGES 注册表 / CODE_RUNNER_LANGUAGES 对齐)。
|
||||
const SUPPORTED_LANGS: &[&str] = &["python", "node", "go", "rust"];
|
||||
/// 仅列 canonical key;别名(js/ts/rs 等)经 normalize_lang 归一到此处某项,
|
||||
/// 按钮不重复展示别名,避免选择拥挤。
|
||||
const SUPPORTED_LANGS: &[&str] = &["python", "node", "go", "rust", "bun"];
|
||||
|
||||
/// 默认示例源码(按语言)。
|
||||
fn default_source(lang: &str) -> String {
|
||||
@ -22,6 +24,8 @@ fn default_source(lang: &str) -> String {
|
||||
"node" => "console.log('Hello from author sandbox');\n[0,1,2].forEach(i => console.log(`line ${i}`));\n".to_string(),
|
||||
"go" => "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello from author sandbox\")\n\tfor i := 0; i < 3; i++ {\n\t\tfmt.Printf(\"line %d\\n\", i)\n\t}\n}\n".to_string(),
|
||||
"rust" => "fn main() {\n println!(\"Hello from author sandbox\");\n for i in 0..3 {\n println!(\"line {}\", i);\n }\n}\n".to_string(),
|
||||
// bun 跑 TypeScript:示例用 TS 类型注解体现语言特性。
|
||||
"bun" => "const greeting: string = 'Hello from author sandbox';\nconsole.log(greeting);\n[0, 1, 2].forEach((i: number) => console.log(`line ${i}`));\n".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user