use std::collections::BTreeMap;
use sml::emit::{
to_custom, to_latex, to_markdown, to_slint, to_svg, to_xml, CustomOptions, CustomRule,
LatexOptions, MarkdownOptions, SlintOptions, SvgOptions, XmlOptions,
};
use sml::{compile_regex, parse_with_features, regex_matches, Feature, FeatureSet, Value};
fn obj(pairs: &[(&str, Value)]) -> Value {
let mut m = BTreeMap::new();
for (k, v) in pairs {
m.insert(k.to_string(), v.clone());
}
Value::Object(m)
}
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
fn split_img(md: &str) -> (String, String, String) {
let t = md.trim();
assert!(t.starts_with("!["), "未进入 img 分支,输出为:{md}");
let rest = &t[2..];
let (alt, tail) = rest.split_once("](").expect("图片语法缺少 `](`");
let tail = tail.trim_end();
let tail = tail.strip_suffix(')').unwrap_or(tail);
match tail.split_once(" \"") {
Some((src, title)) => (
alt.to_string(),
src.to_string(),
title.strip_suffix('"').unwrap_or(title).to_string(),
),
None => (alt.to_string(), tail.to_string(), String::new()),
}
}
#[test]
fn env_feature_cannot_be_bypassed_via_quoted_string() {
let allowed = FeatureSet::baseline().without(Feature::Env);
assert!(parse_with_features("a: $env.PATH\n", allowed).is_err());
let r = parse_with_features("a: \"$env.PATH\"\n", allowed);
assert!(r.is_err(), "引号串 `$env.X` 绕过了 env 特性限制:{r:?}");
}
#[test]
fn env_inline_still_works_when_enabled() {
let mut env = BTreeMap::new();
env.insert("SML_SEC_TEST_VAR".to_string(), "hello".to_string());
let (v, _) = sml::parse_with_features_env(
"a: \"$env.SML_SEC_TEST_VAR\"\nb: $env.SML_SEC_TEST_VAR\n",
FeatureSet::baseline(),
env,
)
.unwrap();
assert_eq!(v.get("a"), Some(&s("hello")));
assert_eq!(v.get("b"), Some(&s("hello")));
}
#[test]
fn env_overrides_do_not_leak_into_process_env() {
let mut env = BTreeMap::new();
env.insert("SML_SEC_OVERRIDE".to_string(), "injected".to_string());
let (v, _) = sml::parse_with_features_env(
"a: $env.SML_SEC_OVERRIDE\n",
FeatureSet::baseline(),
env,
)
.unwrap();
assert_eq!(v.get("a"), Some(&s("injected")));
assert!(std::env::var("SML_SEC_OVERRIDE").is_err());
}
#[test]
fn env_override_shadows_process_env() {
let mut env = BTreeMap::new();
env.insert("SML_SEC_SHADOW".to_string(), "override".to_string());
let (v, _) = sml::parse_with_features_env(
"a: $env.SML_SEC_SHADOW\n",
FeatureSet::baseline(),
env,
)
.unwrap();
assert_eq!(v.get("a"), Some(&s("override")));
}
#[test]
fn markdown_img_alt_and_title_are_sanitized() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("img")),
("src", s("a.png")),
("alt", s("x\" onerror=\"alert(1)")),
("title", s("t\" onload=\"alert(2)")),
]),
)]);
let md = to_markdown(&v, &MarkdownOptions::new()).unwrap();
let (alt, src, title) = split_img(&md);
assert_eq!(src, "a.png", "src 被篡改:{md}");
assert!(
!alt.contains('"'),
"alt 含未转义引号,可闭合 HTML 属性:{md}"
);
assert!(
!title.contains('"'),
"title 含未转义引号,可闭合 HTML 属性:{md}"
);
assert!(
alt.contains(""") && !alt.contains("=\""),
"alt 中的引号未实体化:{md}"
);
}
#[test]
fn markdown_img_alt_cannot_break_out_of_syntax() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("img")),
("src", s("a.png")),
("alt", s("x](javascript:alert(1) \"y")),
]),
)]);
let md = to_markdown(&v, &MarkdownOptions::new()).unwrap();
let (alt, src, _title) = split_img(&md);
assert_eq!(
md.matches("](").count(),
1,
"alt 通过 `](` 重开了链接语法:{md}"
);
assert_eq!(src, "a.png", "src 被 alt 顶替:{md}");
assert!(
!src.contains("javascript:"),
"javascript: 落入 src 位置:{md}"
);
assert!(alt.contains("]"), "alt 中的 `]` 未实体化:{md}");
assert!(!alt.contains('"'), "alt 含裸引号:{md}");
}
#[test]
fn markdown_html_passthrough_rejects_script_tag() {
let v = obj(&[("doc", obj(&[("__type", s("script")), ("text", s("alert(1)"))]))]);
let mut opt = MarkdownOptions::new();
opt.html_passthrough = true;
assert!(
to_markdown(&v, &opt).is_err(),
"透传模式放行了 <script> 标签"
);
}
#[test]
fn markdown_html_passthrough_escapes_body() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("div")),
("text", s("<script>alert(1)</script>")),
]),
)]);
let mut opt = MarkdownOptions::new();
opt.html_passthrough = true;
let md = to_markdown(&v, &opt).unwrap();
assert!(
!md.contains("<script>"),
"透传标签体未转义,可注入任意 HTML:{md}"
);
}
#[test]
fn svg_drops_event_handler_attrs() {
let v = obj(&[(
"root",
obj(&[
("__type", s("svg")),
("onload", s("alert(1)")),
("onclick", s("alert(2)")),
]),
)]);
let out = to_svg(&v, &SvgOptions::default()).unwrap();
assert!(
!out.contains("onload") && !out.contains("onclick"),
"SVG 输出仍含事件处理器属性:{out}"
);
}
#[test]
fn svg_blocks_javascript_uri() {
let v = obj(&[(
"root",
obj(&[("__type", s("a")), ("href", s("javascript:alert(1)"))]),
)]);
let out = to_svg(&v, &SvgOptions::default()).unwrap();
assert!(
!out.contains("javascript:"),
"SVG href 未过滤 javascript: scheme:{out}"
);
}
#[test]
fn svg_keeps_safe_relative_href() {
let v = obj(&[(
"root",
obj(&[("__type", s("a")), ("href", s("#target"))]),
)]);
let out = to_svg(&v, &SvgOptions::default()).unwrap();
assert!(out.contains("href=\"#target\""), "安全相对引用被误杀:{out}");
}
#[test]
fn xml_drops_event_handler_attrs_and_bad_uri() {
let v = obj(&[(
"root",
obj(&[
("__type", s("node")),
("onerror", s("alert(1)")),
("src", s("file:///etc/passwd")),
("href", s("https://example.com/ok")),
]),
)]);
let out = to_xml(&v, &XmlOptions::default()).unwrap();
assert!(!out.contains("onerror"), "XML 输出含事件属性:{out}");
assert!(!out.contains("file:///etc/passwd"), "XML src 未过滤:{out}");
assert!(
out.contains("https://example.com/ok"),
"安全 URL 被误杀:{out}"
);
}
#[test]
fn custom_output_amplification_is_capped() {
let mut v = obj(&[("__type", s("node")), ("text", s("leaf"))]);
for _ in 0..40 {
v = obj(&[("__type", s("node")), ("child", v)]);
}
let opt = CustomOptions {
base: Default::default(),
rules: vec![CustomRule {
match_type: Some("node".to_string()),
match_key: None,
template: "<{value}>{nested}{nested}{nested}".to_string(),
}],
exclude: Default::default(),
include_only: None,
};
let r = to_custom(&v, &opt);
assert!(
r.is_err(),
"输出放大未被上限阻断,实际输出 {} 字节",
r.as_ref().map(|x| x.len()).unwrap_or(0)
);
}
#[test]
fn custom_normal_template_still_works() {
let v = obj(&[("__type", s("node")), ("text", s("hi"))]);
let opt = CustomOptions {
base: Default::default(),
rules: vec![CustomRule {
match_type: Some("node".to_string()),
match_key: None,
template: "<{value}/>".to_string(),
}],
exclude: Default::default(),
include_only: None,
};
assert_eq!(to_custom(&v, &opt).unwrap(), "<hi/>");
}
#[test]
fn latex_documentclass_cannot_inject_preamble() {
let v = obj(&[("doc", obj(&[("__type", s("p")), ("text", s("hello"))]))]);
let mut opt = LatexOptions::default();
opt.documentclass = "article}\\write18{id}\\documentclass{".to_string();
let out = to_latex(&v, &opt).unwrap();
assert!(
!out.contains("\\write18"),
"documentclass 注入了 preamble 代码:{out}"
);
}
#[test]
fn latex_verbatim_end_is_neutralized() {
let body = "safe text\n\\end{verbatim}\\write18{id}\\begin{verbatim}";
let v = obj(&[(
"doc",
obj(&[("__type", s("code")), ("text", s(body))]),
)]);
let out = to_latex(&v, &LatexOptions::default()).unwrap();
assert!(
!out.contains("\\end{verbatim}\\write18"),
"verbatim 结束标记未被中和,内容逃逸到文档顶层:{out}"
);
}
#[test]
fn latex_math_rejects_dangerous_primitives() {
let v = obj(&[(
"doc",
obj(&[("__type", s("math")), ("text", s("x + \\input{/etc/passwd}"))]),
)]);
let mut opt = LatexOptions::default();
opt.math = true;
assert!(
to_latex(&v, &opt).is_err(),
"数学块放行了 \\input 等危险原语"
);
}
#[test]
fn latex_math_accepts_plain_formula() {
let v = obj(&[(
"doc",
obj(&[("__type", s("math")), ("text", s("E = mc^2"))]),
)]);
let mut opt = LatexOptions::default();
opt.math = true;
let out = to_latex(&v, &opt).unwrap();
assert!(out.contains("$E = mc^2$"), "正常公式被误杀:{out}");
}
const CUSTOM_TPL: &str = "rules: [ { match: \"*\" template: \"{key}={value}{nested}\" } ]";
fn first_code_block(md: &str) -> (String, Vec<String>) {
let lines: Vec<&str> = md.lines().collect();
let open = lines
.iter()
.position(|l| l.trim_start().starts_with("```"))
.unwrap_or_else(|| panic!("未输出代码围栏:{md}"));
let rel = lines[open + 1..]
.iter()
.position(|l| l.trim_start().starts_with("```"))
.unwrap_or_else(|| panic!("围栏未闭合,内容逃逸到代码块之外:{md}"));
let close = open + 1 + rel;
let info = lines[open].trim_start().trim_start_matches('`').to_string();
let body: Vec<String> = lines[open + 1..close].iter().map(|l| l.to_string()).collect();
(info, body)
}
#[test]
fn markdown_code_lang_cannot_escape_fence() {
let src = "code { lang: \"js\\n\\n# INJECTED\\n\\n<script>alert(1)</script>\" text: \"body\" }";
let (v, _feats) = parse_with_features(src, FeatureSet::all()).expect("解析失败");
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let (info, body) = first_code_block(&md);
for bad in ["<script>", "alert(1)", "# INJECTED", "\n"] {
assert!(!info.contains(bad), "lang 残留危险内容 {bad:?}:{info:?}");
}
assert_eq!(body, vec!["body".to_string()], "代码体被污染:{body:?}");
}
#[test]
fn markdown_code_lang_keeps_normal_language() {
for lang in ["rust", "c++", "csharp", "objective-c", "f#", "python3.11"] {
let v = obj(&[(
"doc",
obj(&[("__type", s("code")), ("lang", s(lang)), ("text", s("x"))]),
)]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let (info, _) = first_code_block(&md);
assert_eq!(info, lang, "正常语言标注被清洗破坏:{info:?}");
}
}
#[test]
fn markdown_code_body_fence_still_grows() {
let v = obj(&[("__type", s("code")), ("text", s("x\n```\n# ESCAPED\n```"))]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let (_, body) = first_code_block(&md);
assert!(
body.contains(&"# ESCAPED".to_string()),
"body 侧围栏未生效,正常内容丢失:{body:?}"
);
}
fn table_rows(md: &str) -> Vec<String> {
md.lines()
.filter(|l| l.trim_start().starts_with('|'))
.map(|l| l.trim().to_string())
.collect()
}
#[test]
fn markdown_table_cell_cannot_forge_columns() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("table")),
("header", Value::Array(vec![s("A"), s("B")])),
(
"rows",
Value::Array(vec![Value::Array(vec![s("x|y|z"), s("q")])]),
),
]),
)]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let rows = table_rows(&md);
assert_eq!(rows.len(), 3, "行数被伪造:{rows:?}");
for r in &rows {
let cols = r.trim_matches('|').split(" | ").count();
assert_eq!(cols, 2, "列数被伪造:{r:?}");
}
assert!(
rows[2].contains(r"x\|y\|z"),
"单元格内的 | 未转义:{:?}",
rows[2]
);
}
#[test]
fn markdown_table_cell_cannot_forge_rows() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("table")),
("header", Value::Array(vec![s("A"), s("B")])),
(
"rows",
Value::Array(vec![Value::Array(vec![s("x\n| evil | evil |"), s("q")])]),
),
]),
)]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let rows = table_rows(&md);
assert_eq!(rows.len(), 3, "换行伪造了额外表格行:{rows:?}");
}
#[test]
fn markdown_table_keeps_normal_cells() {
let v = obj(&[(
"doc",
obj(&[
("__type", s("table")),
("header", Value::Array(vec![s("name"), s("note")])),
(
"rows",
Value::Array(vec![Value::Array(vec![s("a & b"), s("<tag>")])]),
),
]),
)]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
let rows = table_rows(&md);
assert_eq!(rows.len(), 3, "正常表格行数异常:{rows:?}");
assert!(rows[2].contains("a & b"), "XML 转义丢失:{:?}", rows[2]);
assert!(rows[2].contains("<tag>"), "XML 转义丢失:{:?}", rows[2]);
}
#[test]
fn markdown_field_name_cannot_forge_heading() {
let src = "doc { cfg { \"a**\\n\\n# INJECTED\\n\\n- \": v } }";
let (v, _feats) = parse_with_features(src, FeatureSet::all()).expect("解析失败");
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
for line in md.lines() {
assert!(
!line.trim_start().starts_with("# "),
"字段名伪造了 Markdown 标题:{md}"
);
}
assert!(!md.contains("\n\n\n"), "字段名制造了异常的空行块:{md}");
}
#[test]
fn markdown_field_name_keeps_normal_keys() {
let v = obj(&[(
"cfg",
obj(&[("max_retries", Value::Int(3)), ("a.b", Value::Int(1))]),
)]);
let md = to_markdown(&v, &MarkdownOptions::default()).unwrap();
assert!(md.contains("max_retries"), "常规字段名被破坏:{md}");
assert!(md.contains("a.b"), "常规字段名被破坏:{md}");
}
#[test]
fn regex_step_budget_is_shared_across_start_positions() {
let re = compile_regex(&("a*".repeat(20) + "b"));
let text = "a".repeat(100);
let t = std::time::Instant::now();
let matched = regex_matches(&re, &text);
let elapsed = t.elapsed();
assert!(!matched, "纯 a 文本不应匹配 `a*b`");
assert!(
elapsed < std::time::Duration::from_secs(5),
"ReDoS 预算未按起点共享,耗时 {elapsed:?}(修复前为数十秒级)"
);
}
#[test]
fn regex_still_matches_correctly() {
let re = compile_regex(r"^widget_[0-9][0-9]\.sml$");
assert!(regex_matches(&re, "widget_01.sml"), "正常文件名应匹配");
assert!(!regex_matches(&re, "widget_x1.sml"), "字符类范围误匹配");
assert!(!regex_matches(&re, "other_01.sml"), "不应匹配其它前缀");
let re2 = compile_regex(r"\.sml$");
assert!(regex_matches(&re2, "a/b/c.sml"), "非锚定结尾匹配失效");
assert!(!regex_matches(&re2, "a/b/c.txt"), "非锚定结尾误匹配");
let re3 = compile_regex(r"^ab+c$");
assert!(regex_matches(&re3, "abbc"), "`+` 多次匹配失效");
assert!(!regex_matches(&re3, "abc"), "`+` 现状应等价于 `xx*`");
assert!(!regex_matches(&re3, "ac"), "`+` 误匹配零次");
let re4 = compile_regex(r"^[a-c][a-c]\.txt$");
assert!(regex_matches(&re4, "ab.txt"), "字符类匹配失效");
assert!(!regex_matches(&re4, "ad.txt"), "字符类范围误匹配");
let re5 = compile_regex(r"^a.*z$");
assert!(regex_matches(&re5, "abcz"), "`*` + `.` 匹配失效");
assert!(!regex_matches(&re5, "abcy"), "`*` 结尾误匹配");
}
#[test]
fn slint_string_value_escapes_newlines() {
let v = obj(&[(
"app",
obj(&[
("__type", s("component")),
("name", s("App")),
("label", s("line1\nline2\ttab")),
]),
)]);
let out = to_slint(&v, &SlintOptions::default()).unwrap();
assert!(
out.contains(r#"label: "line1\nline2\ttab";"#),
"Slint 字符串字面量未转义换行/制表符:{out}"
);
let label_line = out.lines().find(|l| l.contains("label:")).unwrap();
assert!(label_line.trim_end().ends_with(';'), "属性未单行终结:{out}");
}
#[test]
fn slint_string_value_keeps_normal_text() {
let v = obj(&[(
"app",
obj(&[
("__type", s("component")),
("name", s("App")),
("label", s(r#"say "hi""#)),
]),
)]);
let out = to_slint(&v, &SlintOptions::default()).unwrap();
assert!(out.contains(r#""say \"hi\"""#), "引号转义丢失:{out}");
}
#[test]
fn custom_exclude_applies_to_text_field() {
let gen = sml::parse(CUSTOM_TPL).unwrap();
let opt = CustomOptions::from_generator(&gen)
.unwrap()
.exclude_fields(&["text"]);
let v = obj(&[("secret", obj(&[("text", s("SUPER_SECRET"))]))]);
let out = to_custom(&v, &opt).unwrap();
assert!(
!out.contains("SUPER_SECRET"),
"exclude:[\"text\"] 被绕过,敏感字段泄漏:{out}"
);
}
#[test]
fn custom_exclude_still_allows_other_fields() {
let gen = sml::parse(CUSTOM_TPL).unwrap();
let opt = CustomOptions::from_generator(&gen)
.unwrap()
.exclude_fields(&["text"]);
let v = obj(&[("rec", obj(&[("text", s("HIDDEN")), ("name", s("VISIBLE"))]))]);
let out = to_custom(&v, &opt).unwrap();
assert!(!out.contains("HIDDEN"), "text 未被排除:{out}");
assert!(out.contains("VISIBLE"), "同对象其它字段被误杀:{out}");
}
#[test]
fn custom_include_only_applies_to_text_field() {
let gen = sml::parse(CUSTOM_TPL).unwrap();
let opt = CustomOptions::from_generator(&gen)
.unwrap()
.include_fields(&["rec", "name"]);
let v = obj(&[("rec", obj(&[("text", s("HIDDEN")), ("name", s("VISIBLE"))]))]);
let out = to_custom(&v, &opt).unwrap();
assert!(!out.contains("HIDDEN"), "include_only 被 text 绕过:{out}");
assert!(out.contains("VISIBLE"), "白名单字段未渲染:{out}");
}