Compare commits

...

3 Commits

Author SHA1 Message Date
xfy
debe7dabe3 fix(test): Docker 集成测试在无 daemon 环境优雅跳过而非失败
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
docker.rs 的 4 个 test_run_in_container_* 此前直接 unwrap Docker 客户端,
在没有 Docker daemon 的环境(如 CI、纯服务端交叉编译检查)会 panic 失败,
拖累整套 cargo test 的退出码——即便这些测试本就无法在那里运行。

加 require_docker() 守卫:socket 缺失(DOCKER_CLIENT == None)或 daemon
无响应(轻量 version() 探测)时返回 None,测试 eprintln + 提前 return。

选动态探测而非 #[ignore]:#[ignore] 会在所有环境一律跳过、需
cargo test --ignored 显式触发;探测守卫让测试在有 Docker 时自动运行,
在无 Docker 的 CI 上静默放行,且在 --nocapture 下可见跳过原因。

验证:cargo test --bin yggdrasil 从 535 passed / 4 failed → 539 passed / 0 failed。
2026-07-16 13:22:49 +08:00
xfy
fa8b1d9968 test: 补齐安全关键路径的单元测试盲区
针对前期测试评估发现的高性价比纯函数与安全不变量盲区补测,
共新增 35 个回归测试(全部通过),分布如下:

- infra/docker.rs (+5): 扩展 build_host_config 的隔离断言——cap_drop=ALL、
  security_opt=no-new-privileges、pids_limit=64、memory_swap==memory(禁 swap)、
  tmpfs 的 exec 仅落在 /tmp、ulimits 只保留 nofile(无 nproc)。这些配置若被
  误删会让容器隔离悄悄失效,此前 test_host_config_generation 仅断言 cpu/memory。

- api/code_runner/execute.rs (+8): validate_exec_request 此前零测试。覆盖
  语言白名单(大小写/空白容忍、多 token 拒绝、未注册拒绝)、源码大小边界
  (恰好 max 放行 / max+1 拒绝)、校验顺序(语言优先于大小)。

- api/database/export.rs (+12): is_simple_ident / parse_source 此前零测试。
  覆盖表名白名单(SQL 注入 / 路径穿越 / Unicode 拒绝)、query: 来源的只读
  AST 校验(INSERT/UPDATE/DELETE/DROP/TRUNCATE/CREATE/ALTER/GRANT 全拦截)。

- api/sanitizer.rs (+10): 补 XSS 攻击向量回归——事件处理器属性(onerror/onload/
  onclick + 大小写混淆)、script/style 连内容移除、iframe/object/svg/form 等
  危险标签整体移除、javascript:/vbscript: scheme(含 HTML 实体编码绕过)、
  data URI 在文章正文路径(allow_data_uri=false)的拒绝、svg data URI 风险点锁定。

顺带记录一个既有可维护性问题:docker.rs 的 4 个 test_run_in_container_*
依赖真实 Docker daemon,无 daemon 环境会失败而非跳过,建议后续加 #[ignore]
或 daemon 探测守卫。本次未改动它们。
2026-07-16 11:55:58 +08:00
xfy
fdf2243299 docs(env): 补全 .env.example 缺失的 5 个环境变量
代码中经 env::var 读取但示例文件遗漏:

- EXPOSE_VERSION_HEADERS (main.rs:232, 安全相关, 控制版本暴露响应头)
- SYSINFO_SAMPLE_SECS (sysinfo_sampler.rs:46, 后台采样间隔)
- CODE_RUNNER_MAX_SOURCE_BYTES (runner_config.rs:67)
- CODE_RUNNER_QUEUE_TIMEOUT_SECS (runner_config.rs:79)
- CODE_RUNNER_TASK_TTL_SECS (runner_config.rs:83)

默认值与代码一致, 注释风格沿用现有分组(安全段 / tuning 段 /
Code Runner 段)。交叉核对确认示例文件无多余项。
2026-07-16 11:42:30 +08:00
5 changed files with 511 additions and 1 deletions

View File

@ -55,6 +55,10 @@ TRUSTED_PROXY_COUNT=0
# 仅对经连接池DB_POOL建立的连接生效作用是防止单条慢查询长时间占用连接拖垮池。
# 需注意PostgreSQL 的总连接数受 max_connections 限制,本超时不影响已占连接的计费。
STATEMENT_TIMEOUT_SECS=30
# 是否附加版本暴露响应头Server / X-Yggdrasil-Version / X-Yggdrasil-Git
# 默认开启;注重安全、不想对外暴露具体版本/commit 时设 0/false/no 关闭。
# bool 解析与 COOKIE_SECURE 一致(识别 0/false/no 为关闭,其余视为开启)。
EXPOSE_VERSION_HEADERS=true
# ─────────────────────────────────────────────────────────────
# WebP 编码配置(仅上传转码 / 图片格式转换时生效)
@ -87,6 +91,11 @@ MIGRATE_STARTUP_TIMEOUT_SECS=30
# 此 TTL 是唯一有效的 SSR 缓存失效手段——意味着文章更新后最长可能滞后 TTL 秒才可见。
SSR_CACHE_SECS=3600
# 系统信息后台采样间隔,单位秒(默认 0.5)。
# 仅影响 /admin/system 服务器状态页的刷新粒度;服务端函数读取快照零成本,前端可高频轮询。
# 支持小数(如 0.2),下限 0.05;过低会徒增 CPU 占用而无可见收益。
SYSINFO_SAMPLE_SECS=0.5
# HTTP 响应压缩算法。逗号分隔,大小写不敏感。
# 支持gzip、brotli可简写为 br、deflate、zstd。
# 默认关闭压缩(不设环境变量时等同 "off")。
@ -141,6 +150,14 @@ CODE_RUNNER_MAX_CPU_CORES=2.0
CODE_RUNNER_MAX_MEMORY_MB=1024
CODE_RUNNER_MAX_TIMEOUT_SECS=30
CODE_RUNNER_MAX_OUTPUT_BYTES=1048576
# 容器可接收的源码字节上限(默认 65536。超过即拒绝防止超大输入撑爆容器。
CODE_RUNNER_MAX_SOURCE_BYTES=65536
# 任务排队等容器槽的最长时间,单位秒(默认 30
# 受 CODE_RUNNER_MAX_CONCURRENT 限制,并发满时新任务排队,超时则失败。
CODE_RUNNER_QUEUE_TIMEOUT_SECS=30
# 进度表DashMap中任务条目的存活时长单位秒默认 300
# 超期后任务记录被 gc_old_tasks 清理;前端轮询结果需在此窗口内完成。
CODE_RUNNER_TASK_TTL_SECS=300
# 默认全开:注册表里的语言均可用。设置则收窄到逗号分隔列表(如 python,node
# CODE_RUNNER_LANGUAGES=python,node
DOCKER_SOCKET_PATH=/var/run/docker.sock

View File

@ -346,3 +346,91 @@ pub async fn get_exec_result(task_id: String) -> Result<ExecTask, ServerFnError>
Err(ServerFnError::new("找不到指定的任务".to_string()))
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
fn req(language: &str, source: &str) -> ExecRequest {
ExecRequest {
language: language.to_string(),
source: source.to_string(),
overrides: None,
}
}
#[test]
fn validate_accepts_registered_language() {
// python/node/go/rust 默认注册,应放行。
for lang in ["python", "node", "go", "rust"] {
assert!(
validate_exec_request(&req(lang, "x")).is_ok(),
"{lang} 应被支持"
);
}
}
#[test]
fn validate_language_is_case_and_whitespace_insensitive() {
// is_supported_lang 内部 trim+lowercase校验链应透传该容忍。
assert!(validate_exec_request(&req("Python", "x")).is_ok());
assert!(validate_exec_request(&req(" RUST ", "x")).is_ok());
assert!(validate_exec_request(&req("Go", "x")).is_ok());
}
#[test]
fn validate_rejects_unregistered_language() {
// 未注册语言 / 命令注入尝试都应在白名单阶段被拒。
assert!(validate_exec_request(&req("c", "x")).is_err());
assert!(validate_exec_request(&req("bash", "x")).is_err());
assert!(validate_exec_request(&req("python2", "x")).is_err());
assert!(validate_exec_request(&req("", "x")).is_err());
}
#[test]
fn validate_rejects_multi_token_language() {
// 语言字段不会到达容器层:只有命中 LANGUAGES 的 key 才放行,
// 否则被拒。任何多 token / 含 shell 元字符的值都因找不到注册项而拒绝。
assert!(validate_exec_request(&req("python; rm -rf /", "x")).is_err());
assert!(validate_exec_request(&req("python node", "x")).is_err());
assert!(validate_exec_request(&req("python$(whoami)", "x")).is_err());
}
#[test]
fn validate_language_tolerates_surrounding_whitespace() {
// is_supported_lang 内部 trim+lowercase首尾空白含换行被吃掉后等于 "python"。
// 锁定该契约——语言字段只用于 HashMap 查 key空白无害。
assert!(validate_exec_request(&req("python\n", "x")).is_ok());
assert!(validate_exec_request(&req("\tpython\t", "x")).is_ok());
}
#[test]
fn validate_rejects_source_exceeding_max_bytes() {
let max = RUNNER_CONFIG.max_source_bytes as usize;
// 恰好 max 放行max+1 拒绝——边界精确,防止 off-by-one 让攻击者多塞 1 字节。
let exactly = "a".repeat(max);
assert!(
validate_exec_request(&req("python", &exactly)).is_ok(),
"源码恰好等于上限应放行"
);
let over = "a".repeat(max + 1);
let err =
validate_exec_request(&req("python", &over)).expect_err("超出上限应拒绝");
assert!(err.to_string().contains("过大"), "错误信息应提及大小: {err}");
}
#[test]
fn validate_empty_source_accepted_for_supported_lang() {
// 空源码不是校验层的职责(容器层处理),这里只验语言/大小。
// 锁定该契约:空源码 + 合法语言 → Ok避免未来误把空当拒绝。
assert!(validate_exec_request(&req("python", "")).is_ok());
}
#[test]
fn validate_checks_language_before_size() {
// 不支持语言 + 巨大源码:应先因语言被拒(而非先报大小)。
let huge = "a".repeat((RUNNER_CONFIG.max_source_bytes as usize) + 100);
let err = validate_exec_request(&req("brainfuck", &huge)).unwrap_err();
assert!(err.to_string().contains("语言"), "应先报语言错误: {err}");
}
}

View File

@ -231,3 +231,148 @@ fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
}
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
// ---- is_simple_ident表名白名单防 SQL 注入与路径穿越)----
#[test]
fn simple_ident_accepts_legal_names() {
assert!(is_simple_ident("posts"));
assert!(is_simple_ident("user_posts"));
assert!(is_simple_ident("table1"));
assert!(is_simple_ident("a"));
assert!(is_simple_ident("_private"));
assert!(is_simple_ident("ABC123xyz"));
}
#[test]
fn simple_ident_rejects_empty_and_whitespace() {
assert!(!is_simple_ident(""));
assert!(!is_simple_ident(" "));
}
#[test]
fn simple_ident_rejects_injection_vectors() {
// SQL 注入:引号、分号、注释、空格分隔
assert!(!is_simple_ident("posts; DROP TABLE users"));
assert!(!is_simple_ident("posts' OR '1'='1"));
assert!(!is_simple_ident("posts--"));
assert!(!is_simple_ident("public.posts"));
assert!(!is_simple_ident("posts where 1=1"));
// 路径穿越
assert!(!is_simple_ident("../etc/passwd"));
assert!(!is_simple_ident("..\\windows"));
assert!(!is_simple_ident("/etc/passwd"));
// 反引号 / 双引号标识符语法
assert!(!is_simple_ident("`posts`"));
assert!(!is_simple_ident("\"posts\""));
// 连字符(常见合法表名片段,但本白名单禁止,防 `a-b` 被 SQL 解析为减法)
assert!(!is_simple_ident("my-posts"));
}
#[test]
fn simple_ident_rejects_unicode_and_non_ascii() {
assert!(!is_simple_ident("文章"));
assert!(!is_simple_ident("posts²"));
assert!(!is_simple_ident("café"));
}
// ---- parse_source来源解析 + 只读校验(安全入口)----
#[test]
fn parse_table_source_builds_select_star() {
let (sql, name) = parse_source("table:posts").unwrap();
assert_eq!(sql, "SELECT * FROM \"posts\"");
assert_eq!(name, "posts");
}
#[test]
fn parse_table_source_trims_whitespace() {
let (sql, name) = parse_source("table: posts ").unwrap();
assert_eq!(sql, "SELECT * FROM \"posts\"");
assert_eq!(name, "posts");
}
#[test]
fn parse_table_source_rejects_bad_name() {
// 非法表名(注入/穿越/空)必须在拼进 SQL 前被拒,防 SQL 注入。
for bad in [
"table:",
"table: ",
"table:posts; DROP TABLE users",
"table:../secret",
"table:a b",
"table:\"x\"",
] {
let (code, msg) = parse_source(bad).expect_err(bad);
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(msg.contains("表名"), "{bad}: {msg}");
}
}
#[test]
fn parse_query_source_accepts_select_and_explain() {
assert!(parse_source("query:SELECT * FROM posts").is_ok());
assert!(parse_source("query:SELECT id, title FROM posts WHERE id > 0").is_ok());
assert!(parse_source("query:EXPLAIN SELECT * FROM posts").is_ok());
assert!(parse_source("query:SELECT 1").is_ok());
// 复杂但只读的查询
assert!(parse_source("query:WITH t AS (SELECT 1) SELECT * FROM t").is_ok());
}
#[test]
fn parse_query_source_rejects_all_write_operations() {
// 每一种写/破坏操作都必须被 AST 校验拦截——这是数据导出的核心安全不变量。
for evil in [
"query:INSERT INTO posts VALUES (1)",
"query:UPDATE posts SET title='x'",
"query:DELETE FROM posts",
"query:DROP TABLE posts",
"query:TRUNCATE posts",
"query:CREATE TABLE evil (id int)",
"query:ALTER TABLE posts ADD COLUMN x int",
"query:GRANT SELECT ON posts TO public",
// 即便嵌在 SELECT 里,子查询的写操作也要拒
"query:INSERT INTO logs VALUES (1) RETURNING 1",
] {
let (code, msg) = parse_source(evil).expect_err(evil);
assert_eq!(code, StatusCode::BAD_REQUEST, "{evil}");
// 写操作要么被 AST 校验拒("只读"),要么 sqlparser 解析阶段就失败
assert!(
msg.contains("只读") || msg.contains("解析失败"),
"{evil}: {msg}"
);
}
}
#[test]
fn parse_query_source_rejects_unparseable_sql() {
for bad in ["query:这不是SQL", "query:SELECT FROM", "query:@#$%", "query:1+1"] {
assert!(
parse_source(bad).is_err(),
"{bad} 应解析失败或被拒"
);
}
}
#[test]
fn parse_query_source_allows_empty_result_set() {
// sqlparser 容忍纯分号(解析为零语句),`any()` 在空集上为 false → 放行。
// 锁定该契约:这只产生空导出,无数据泄露,属可接受行为;
// 若未来想收紧为"空查询拒绝",此测试会提醒你更新。
assert!(parse_source("query:;;;").is_ok());
}
#[test]
fn parse_source_rejects_unknown_prefix() {
let (code, msg) = parse_source("unknown:posts").expect_err("未知前缀");
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(msg.contains("source"));
// 完全无前缀的裸字符串也应被拒
assert!(parse_source("posts").is_err());
assert!(parse_source("").is_err());
}
}

View File

@ -713,4 +713,149 @@ mod tests {
"未知 data-* 应被剥离, got: {result}"
);
}
// ---- XSS 攻击向量回归:属性白名单是这里的核心防线 ----
// 这些测试锁定"非白名单属性被剥离"这一不变量——若有人放宽属性过滤,
// 经典 XSS 向量就会重新可用,测试会在那一步失败。
#[test]
fn clean_html_strips_event_handler_attributes() {
// onerror/onload/onclick 等事件处理器属性全不在白名单,必须被移除。
// 即便 <img> 本身合法onerror 也不能留下。
let cases = [
r#"<img src="x" onerror="alert(1)">"#,
r#"<img src=x onerror=alert(1)>"#,
r#"<body onload="alert(1)">"#,
r#"<div onclick="alert(1)">x</div>"#,
r#"<a href="/x" onmouseover="alert(1)">x</a>"#,
r#"<svg onload="alert(1)"></svg>"#,
];
for input in cases {
let result = clean_html(input);
assert!(
!result.contains("onerror")
&& !result.contains("onload")
&& !result.contains("onclick")
&& !result.contains("onmouseover"),
"事件处理器属性应被剥离, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_strips_event_handler_attribute_with_mixed_case() {
// 大小写混淆绕过尝试EvEr、大写、混合都应被拦属性名匹配应大小写不敏感地拒绝
for attr in ["OnErRoR", "ONERROR", "On_Error".replace('_', "or").as_str()] {
let input = format!(r#"<img src="x" {attr}="alert(1)">"#);
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains("onerror"),
"大小写混淆的事件处理器应被剥离: {input} -> {result}"
);
}
}
#[test]
fn clean_html_removes_script_tag_and_content() {
// script 在 CLEAN_CONTENT_TAGS标签连同内容一起移除而非转义后保留
let result = clean_html("<p>hi</p><script>alert(1)</script><p>bye</p>");
assert!(!result.contains("script"), "script 标签应被完全移除: {result}");
assert!(!result.contains("alert"), "script 内容应被清除: {result}");
assert!(result.contains("hi") && result.contains("bye"), "周围内容应保留: {result}");
}
#[test]
fn clean_html_removes_style_tag_and_content() {
// style 也走 CLEAN_CONTENT_TAGSCSS 注入expression()、@import随内容一起移除。
let result = clean_html("<style>body{background:url(javascript:alert(1))}</style><p>x</p>");
assert!(!result.contains("style"), "style 标签应被移除: {result}");
assert!(!result.contains("javascript"), "style 内危险内容应被清除: {result}");
}
#[test]
fn clean_html_drops_dangerous_tags_entirely() {
// 这些标签不在白名单:整体移除(含子树),无法用于 XSS / 数据外泄。
for tag in ["iframe", "object", "embed", "form", "svg", "math", "base", "meta"] {
let input = format!("<{tag} src=\"javascript:alert(1)\"></{tag}>");
let result = clean_html(&input);
assert!(
!result.to_lowercase().contains(&format!("<{tag}")),
"<{tag}> 不在白名单应被移除: {result}"
);
}
}
#[test]
fn clean_html_drops_javascript_scheme_in_href_and_src() {
// 即便标签合法javascript:/vbscript: scheme 必须被拒is_safe_url 防线)。
let cases = [
r#"<a href="javascript:alert(1)">x</a>"#,
r#"<a href="vbscript:msgbox(1)">x</a>"#,
r#"<img src="javascript:alert(1)">"#,
// 编码绕过尝试HTML 实体编码的 javascript:
r#"<a href="&#106;avascript:alert(1)">x</a>"#,
r#"<a href="java&#115;cript:alert(1)">x</a>"#,
];
for input in cases {
let result = clean_html(input);
// href/src 值里不应残留可执行的 javascript scheme属性可能被整段移除或值被清空
// 至少裸的 "javascript:" 字面量不应在 href/src 属性值中出现。
let lower = result.to_lowercase();
assert!(
!lower.contains("javascript:") && !lower.contains("vbscript:"),
"危险 scheme 应被移除, input: {input}, got: {result}"
);
}
}
#[test]
fn clean_html_data_uri_blocked_in_article_body() {
// clean_html 配置 allow_data_uri: false——所有 data URI含 image/png都应被拒。
// 这是文章正文路径的契约;评论路径同样。
let result = clean_html(r#"<img src="data:image/png;base64,iVBORw0KGgo=처리">"#);
assert!(
!result.contains("data:image/png"),
"文章正文应禁用 data URI: {result}"
);
}
#[test]
fn clean_html_data_uri_text_html_blocked_even_if_flag_true() {
// 即便 allow_data_uri=trueis_safe_data_uri 也只放行图片类型;
// data:text/html可执行脚本永远拒绝。直接测内部函数锁定该不变量。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(!is_safe_url("data:text/html,<script>alert(1)</script>", &schemes, true));
assert!(!is_safe_url(
"data:application/javascript,alert(1)",
&schemes,
true
));
// 安全图片类型在 flag=true 时放行
assert!(is_safe_url("data:image/png;base64,iVBOR=", &schemes, true));
}
#[test]
fn clean_html_svg_data_uri_carries_risk_even_when_allowed() {
// is_safe_data_uri 允许 image/svg+xml——SVG 内可嵌 <script>。
// 这里锁定当前行为flag=true 时 svg data URI 被放行(调用方需自行评估风险),
// 并用注释标记这是一个潜在风险点(文章正文 allow_data_uri=false 已堵住)。
let schemes = DEFAULT_ALLOWED_SCHEMES.clone();
assert!(
is_safe_url("data:image/svg+xml,<svg></svg>", &schemes, true),
"svg data URI 在 flag=true 时当前被放行(已知风险点)"
);
// 但文章正文路径 flag=falsesvg data URI 同样被拒
assert!(!is_safe_url(
"data:image/svg+xml,<svg><script>alert(1)</script></svg>",
&schemes,
false
));
}
#[test]
fn clean_comment_html_strips_event_handlers() {
// 评论路径(更严格)同样不能漏掉事件处理器。
let result = clean_comment_html(r#"<p onclick="alert(1)">x</p>"#);
assert!(!result.contains("onclick"), "评论 XSS 向量: {result}");
}
}

View File

@ -611,11 +611,112 @@ mod tests {
assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
assert_eq!(host_config.readonly_rootfs, Some(true));
assert_eq!(host_config.network_mode.as_deref(), Some("none"));
// 隔离不变量:这些配置若被误删,容器隔离会悄悄失效。
// 单独断言而非隐含在端到端测试里,确保回归可见。
assert_eq!(host_config.cap_drop.as_deref(), Some(&["ALL".to_string()][..]));
assert_eq!(
host_config.security_opt.as_deref(),
Some(&["no-new-privileges".to_string()][..])
);
assert_eq!(host_config.pids_limit, Some(64));
// memory_swap == memory 才是真正禁用 swap二者不等价于可换出到磁盘
assert_eq!(host_config.memory_swap, host_config.memory);
assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
}
#[test]
fn host_config_ulimits_drops_only_nofile() {
// nproc 在容器内对 non-root 用户有害(初始 exec /bin/sh 直接 EAGAIN
// 只保留 nofile=64。若有人加回 nproc这里会失败。
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
let ulimits = host_config.ulimits.expect("ulimits must be set");
assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
let nf = &ulimits[0];
assert_eq!(nf.name.as_deref(), Some("nofile"));
assert_eq!(nf.soft, Some(64));
assert_eq!(nf.hard, Some(64));
}
#[test]
fn host_config_tmpfs_has_exec_on_tmp_only() {
// /tmp 必须 exec编译型语言把产物落在 /tmp 后再 exec
// /code、/run 不带 exec默认 noexec。若误给 /code 也加 exec测试失败。
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
assert_eq!(tmpfs.len(), 3);
assert!(tmpfs["/tmp"].contains("exec"));
assert!(!tmpfs["/code"].contains("exec"));
assert!(!tmpfs["/run"].contains("exec"));
}
#[test]
fn host_config_network_mode_follows_allow_network() {
let base = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
assert_eq!(build_host_config(&base).network_mode.as_deref(), Some("none"));
let mut net = base;
net.allow_network = true;
assert_eq!(build_host_config(&net).network_mode.as_deref(), Some("bridge"));
}
#[test]
fn host_config_cpu_and_memory_scale_with_limits() {
// 资源钳制是安全边界断言换算公式不漂移cpu_cores * 100_000memory_mb * 1MiB
let limits = ResourceLimits {
cpu_cores: 2.0,
memory_mb: 512,
timeout_secs: 5,
output_bytes: 1024,
allow_network: false,
};
let host_config = build_host_config(&limits);
assert_eq!(host_config.cpu_quota, Some(200_000));
assert_eq!(host_config.cpu_period, Some(100_000));
assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
}
/// 探测 Docker daemon 是否可用socket 缺失(`DOCKER_CLIENT == None`)或
/// daemon 无响应时返回 `None`。集成测试用它做动态守卫——daemon 在则跑,
/// 不在则显式跳过eprintln + 提前 return而非 panic 失败。
///
/// 这比 `#[ignore]` 更合适:`#[ignore]` 会在所有环境(含有 Docker 的开发机)
/// 一律跳过、且需 `cargo test --ignored` 显式触发;探测守卫让这些测试在
/// 有 Docker 时自动运行、在无 Docker 的 CI 上静默放行。
async fn require_docker() -> Option<&'static Docker> {
let docker = DOCKER_CLIENT.as_ref()?;
// socket 在但 daemon 挂了的情况靠这一步轻量只读调用兜住。
docker.version().await.ok()?;
Some(docker)
}
#[tokio::test]
#[serial_test::serial]
async fn test_run_in_container_success() {
if require_docker().await.is_none() {
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
return;
}
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
@ -642,6 +743,10 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn test_run_in_container_output_truncation() {
if require_docker().await.is_none() {
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
return;
}
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
@ -668,6 +773,10 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn test_run_in_container_timeout() {
if require_docker().await.is_none() {
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
return;
}
let limits = ResourceLimits {
cpu_cores: 1.0,
memory_mb: 128,
@ -691,7 +800,13 @@ mod tests {
#[serial_test::serial]
async fn test_run_in_container_cancellation() {
use bollard::query_parameters::ListContainersOptions;
let docker = get_docker().expect("test requires a running Docker daemon");
let docker = match require_docker().await {
Some(d) => d,
None => {
eprintln!("skip: Docker daemon 不可用(未安装或未运行)");
return;
}
};
let before = docker
.list_containers(Some(ListContainersOptions {