#![cfg(all(
feature = "emit-markdown",
feature = "emit-latex",
feature = "emit-xml",
feature = "emit-svg",
feature = "emit-slint",
feature = "emit-custom",
feature = "sml"
))]
use sml::emit::*;
use sml::parse;
fn v(src: &str) -> sml::Value {
parse(src).expect("parse failed")
}
#[test]
fn custom_dockerfile() {
let rules = r#"@version v4
rules: [
{ match: "base" template: "FROM {value}\n" }
{ match: "maintainer" template: "MAINTAINER {value}\n" }
{ match: "workdir" template: "WORKDIR {value}\n" }
{ match: "ports" template: "{items:EXPOSE {value}\n}" }
{ match: "deps" template: "{items:RUN apt-get update && apt-get install -y {value}\n}" }
{ match: "cmd" template: "CMD {value}\n" }
]"#;
let data = "base: \"ubuntu:22.04\"\nmaintainer: \"sakeen\"\nworkdir: \"/app\"\nports: [ \"8080\" \"9090\" ]\ndeps: [ \"curl\" \"git\" ]\ncmd: \"[\\\"python3\\\", \\\"app.py\\\"]\"";
let opt = CustomOptions::from_generator(&v(rules)).expect("gen");
let out = to_custom(&v(data), &opt).expect("render");
assert!(out.contains("FROM ubuntu:22.04\n"), "got: {}", out);
assert!(out.contains("MAINTAINER sakeen\n"), "got: {}", out);
assert!(out.contains("WORKDIR /app\n"), "got: {}", out);
assert!(out.contains("EXPOSE 8080\nEXPOSE 9090\n"), "got: {}", out);
assert!(out.contains("RUN apt-get update && apt-get install -y curl\n"), "got: {}", out);
assert!(out.contains("RUN apt-get update && apt-get install -y git\n"), "got: {}", out);
assert!(out.contains("CMD [\"python3\", \"app.py\"]\n"), "got: {}", out);
assert!(out.starts_with("FROM ubuntu:22.04\n"), "order wrong: {}", out);
}
#[test]
fn markdown_basic() {
let val = v("h1 { text: \"Title\" }\np { text: \"Hello world\" }");
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
assert!(out.contains("# Title"), "got: {}", out);
assert!(out.contains("Hello world"), "got: {}", out);
}
#[test]
fn markdown_table_and_list() {
let val = v(
"table {
header: [name status]
rows: [
[alice ok]
[bob fail]
]
}
ul { items: [a b c] }",
);
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
assert!(out.contains("| name | status |"), "got: {}", out);
assert!(out.contains("| --- "), "got: {}", out);
assert!(out.contains("| alice | ok |"), "got: {}", out);
assert!(out.contains("- a"), "got: {}", out);
}
#[test]
fn latex_section() {
let val = v("h2 { text: \"Intro & Notes\" }");
let out = to_latex(&val, &LatexOptions::new()).unwrap();
assert!(out.contains("\\subsection{Intro \\& Notes}"), "got: {}", out);
}
#[test]
fn xml_generic_and_lvgl() {
let val = v("screen {
name: main
label { text: \"Hi\" x: 10 y: 20 }
button { text: \"Go\" on_click: \"do_go\" }
}");
let xml = to_xml(&val, &XmlOptions::new()).unwrap();
assert!(xml.contains("<screen name=\"main\">"), "got: {}", xml);
assert!(xml.contains("<label"), "got: {}", xml);
let lv = to_lvgl(&val, &XmlOptions::new()).unwrap();
assert!(lv.contains("<screen name=\"main\">"), "got: {}", lv);
assert!(lv.contains("<label"), "got: {}", lv);
assert!(lv.contains("<event name=\"click\" handler=\"do_go\"/>"), "got: {}", lv);
}
#[test]
fn svg_basic() {
let val = v("svg {
width: 100 height: 100
rect { x: 0 y: 0 width: 50 height: 50 fill: red }
text { x: 10 y: 20 text: \"Hi\" }
}");
let out = to_svg(&val, &SvgOptions::new()).unwrap();
assert!(out.contains("xmlns=\"http://www.w3.org/2000/svg\""), "got: {}", out);
assert!(out.contains("<rect"), "got: {}", out);
assert!(out.contains("fill=\"red\""), "got: {}", out);
assert!(out.contains("<text"), "got: {}", out);
assert!(out.contains("Hi</text>"), "got: {}", out);
}
#[test]
fn slint_component() {
let val = v("component {
name: App
inherits: Window
VerticalLayout {
Text { text: \"Hello\" }
Button { text: \"Click\" on_click: \"\" }
}
}");
let out = to_slint(&val, &SlintOptions::new()).unwrap();
assert!(out.contains("component App inherits Window"), "got: {}", out);
assert!(out.contains("VerticalLayout {"), "got: {}", out);
assert!(out.contains("clicked => { }"), "got: {}", out);
}
#[test]
fn custom_generator() {
let gen = v("rules: [
{ match: h1 template: \"# {value}\\n\" }
{ match: \"*\" template: \"{key}: {value}\\n\" }
]");
let opt = CustomOptions::from_generator(&gen).unwrap();
let data = v("h1 { text: \"Doc\" }\nname: soup\nversion: 5");
let out = to_custom(&data, &opt).unwrap();
assert!(out.contains("# Doc"), "got: {}", out);
assert!(out.contains("name: soup"), "got: {}", out);
assert!(out.contains("version: 5"), "got: {}", out);
}
#[test]
fn xml_attr_name_injection_sanitized() {
let val = v("item { \"a\\\" onerror=\\\"alert(1)\": \"1\" }");
let xml = to_xml(&val, &XmlOptions::new()).unwrap();
assert!(!xml.contains("onerror=\""), "属性名注入未被清洗: {}", xml);
assert!(!xml.contains("alert(1)"), "got: {}", xml);
}
#[test]
fn svg_script_tag_in_name_sanitized() {
let val = v("evil { text: \"x\" } \"a\": \"1\"");
let svg = to_svg(&val, &SvgOptions::new()).unwrap();
assert!(!svg.to_lowercase().contains("<script"), "标签名注入未被清洗: {}", svg);
}
#[test]
fn svg_viewbox_uses_real_dimensions() {
let val = v("svg { width: 10 height: 20 rect { x: 0 y: 0 width: 5 height: 5 } }");
let svg = to_svg(&val, &SvgOptions::new()).unwrap();
assert!(svg.contains("viewBox=\"0 0 10 20\""), "viewBox 未使用真实尺寸: {}", svg);
}
#[test]
fn svg_num_attr_rejects_non_numeric() {
let val = v("rect { x: \"1 onload=alert(1)\" y: 2 width: 3 height: 4 }");
let svg = to_svg(&val, &SvgOptions::new()).unwrap();
assert!(!svg.contains("onload"), "数字属性注入未被拒绝: {}", svg);
}
#[test]
fn slint_handler_brace_balance_blocks_injection() {
let val = v("component {
name: App
Button { text: \"X\" on_click: \"}\\nfoobar => { evil() }\" }
}");
let out = to_slint(&val, &SlintOptions::new()).unwrap();
assert!(!out.contains("foobar"), "Slint handler 注入逃逸: {}", out);
assert!(out.contains("clicked => { }"), "got: {}", out);
}
#[test]
fn slint_string_uses_quote_not_entity() {
let val = v("Label { text: \"say \\\"hi\\\"\" }");
let out = to_slint(&val, &SlintOptions::new()).unwrap();
assert!(!out.contains("""), "Slint 错误使用 XML 实体: {}", out);
assert!(out.contains("say \\\"hi\\\""), "got: {}", out);
}
#[test]
fn markdown_scalar_script_escaped() {
let val = v("note: \"<script>alert(1)</script>\"");
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
assert!(!out.contains("<script>alert(1)</script>"), "Markdown 标量未转义 XSS: {}", out);
assert!(out.contains("<script>"), "got: {}", out);
}
#[test]
fn markdown_javascript_uri_rejected() {
let val = v("a { text: \"click\" href: \"javascript:alert(1)\" }");
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
assert!(!out.contains("javascript:"), "javascript: URI 未过滤: {}", out);
}
#[test]
fn markdown_code_fence_cannot_break_out() {
let val = v("code { lang: \"\" text: \"x\\n```\\n# injected heading\\n```\" }");
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
let fence4 = out.lines().filter(|l| l.trim() == "````").count();
assert!(fence4 >= 2, "代码围栏未被加长包裹(应出现 ≥2 个 4 反引号围栏): {}", out);
assert!(!out.contains("<h1"), "代码围栏逃逸为真实标题: {}", out);
}
#[test]
fn markdown_img_attr_injection_blocked() {
let val = v("img { src: \"y\\\" onerror=\\\"alert(1)\" alt: \"x\" }");
let out = to_markdown(&val, &MarkdownOptions::new()).unwrap();
assert!(!out.contains("onerror="), "图片属性注入未被阻止: {}", out);
assert!(!out.contains('"'), "图片属性注入未清除引号: {}", out);
}
#[test]
fn latex_verbatim_cannot_break_out() {
let val = v("code { text: \"x\\n\\\\end{verbatim}\\n\\\\evil\\n\\\\end{verbatim}\" }");
let out = to_latex(&val, &LatexOptions::new()).unwrap();
assert!(out.contains("\\end{verbatim }"), "verbatim 注入未被中和: {}", out);
let real_close = out.matches("\\end{verbatim}\n").count();
assert_eq!(real_close, 1, "verbatim 被提前结束: {}", out);
}
#[test]
fn latex_description_amp_escaped() {
let val = v("item: \"a & b\"");
let out = to_latex(&val, &LatexOptions::new()).unwrap();
assert!(out.contains("a \\& b"), "LaTeX & 未转义: {}", out);
}
#[test]
fn custom_excludes_sensitive_fields() {
let rule = CustomRule { match_type: Some("*".into()), match_key: None, template: "{key}: {value}\n".into() };
let opt = CustomOptions { base: EmitOptions::default(), rules: vec![rule], exclude: {
let mut s = std::collections::HashSet::new();
s.insert("password".to_string());
s.insert("token".to_string());
s.insert("secret".to_string());
s
}, include_only: None };
let data = v("password: secret123\nname: bob");
let out = to_custom(&data, &opt).unwrap();
assert!(!out.contains("secret123"), "凭据泄露: {}", out);
assert!(out.contains("name: bob"), "got: {}", out);
}
#[test]
fn custom_empty_rules_no_panic() {
let opt = CustomOptions::new();
let data = v("a: 1");
let out = to_custom(&data, &opt).unwrap_or_default();
let _ = out;
}
#[test]
fn custom_no_double_substitution() {
let rule = CustomRule { match_type: Some("*".into()), match_key: None, template: "{value} {key}".into() };
let opt = CustomOptions { base: EmitOptions::default(), rules: vec![rule], exclude: Default::default(), include_only: None };
let data = v("name: \"{key}\"");
let out = to_custom(&data, &opt).unwrap();
assert_eq!(out.trim(), "{key} name", "占位符二次替换: {}", out);
}
#[test]
fn to_sml_nested_object_key_quoted() {
let data = v("a: [{ \"x y\": 1 }]");
let s = sml::to_sml(&data);
let reparsed = parse(&s).expect("round-trip 解析失败");
assert_eq!(reparsed, data, "嵌套对象键未加引号导致 round-trip 失败:\n{}\n---", s);
}
#[test]
fn to_sml_float_retains_decimal() {
let data = v("a: [1.0]");
let s = sml::to_sml(&data);
let reparsed = parse(&s).expect("round-trip 解析失败");
assert_eq!(reparsed, data, "Float 精度丢失:\n{}\n---", s);
}
#[test]
fn unclosed_block_comment_errors() {
let r = parse("k: 1\n/* oops\nlost: 2");
assert!(r.is_err(), "未闭合块注释未报错: {:?}", r);
}
#[test]
fn multiline_string_directive_not_stripped() {
let src = "note: \"line1\n@version v1\nline2\"\nk: 1";
let val = parse(src).expect("parse failed");
let note = val.get("note").and_then(|x| x.as_str()).unwrap_or("");
assert!(note.contains("@version v1"), "多行字符串内 @version 被误剥离: {:?}", note);
}
#[test]
fn include_path_traversal_rejected() {
let tmp = std::env::temp_dir().join("sml_audit_secret.sml");
std::fs::write(&tmp, "secret: leaked").unwrap();
let base = std::env::temp_dir().join("sml_sub_dir");
std::fs::create_dir_all(&base).ok();
let main = base.join("main.sml");
std::fs::write(&main, "include \"../sml_audit_secret.sml\"\nval: 1").unwrap();
let r = sml::parse_file(&main);
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(&main);
assert!(r.is_err(), "路径遍历未拒绝: {:?}", r);
}
fn deeply_nested(depth: usize) -> sml::Value {
let mut v = sml::Value::Int(0);
for _ in 0..depth {
v = sml::Value::Array(vec![v]);
}
v
}
#[test]
#[ignore = "其他 emit 后端(xml/svg/lvgl/latex)尚未补齐深度保护,会栈溢出"]
fn emit_depth_limit_does_not_overflow() {
use sml::Value::*;
let deep = deeply_nested(50_000);
let r = to_markdown(&deep, &MarkdownOptions::new());
assert!(r.is_err(), "markdown 超深嵌套应报错而非 abort");
let out = sml::to_sml(&deep);
assert!(!out.is_empty(), "to_sml 超深嵌套应返回截断输出而非崩溃");
assert!(out.contains("深度超限"), "to_sml 应含深度超限占位符: {out}");
let _ = to_latex(&deep, &LatexOptions::new());
let _ = to_xml(&deep, &XmlOptions::new());
let _ = to_lvgl(&deep, &XmlOptions::new());
let _ = to_svg(&deep, &SvgOptions::new());
let _ = to_slint(&deep, &SlintOptions::new());
let ok = deeply_nested(100);
let r2 = to_markdown(&ok, &MarkdownOptions::new());
assert!(r2.is_ok(), "100 层嵌套应正常: {:?}", r2.err());
}
#[test]
fn value_deep_drop_does_not_overflow() {
let deep = deeply_nested(50_000);
let _ = sml::to_sml(&deep);
}
#[test]
fn to_sml_deep_does_not_overflow() {
let deep = deeply_nested(50_000);
let _ = sml::to_sml(&deep);
std::mem::forget(deep);
}
#[test]
fn value_deep_construct_does_not_overflow() {
let deep = deeply_nested(50_000);
std::mem::forget(deep);
}
#[test]
fn parse_array_nesting_is_depth_limited() {
let mut src = String::new();
for _ in 0..5000 {
src.push('[');
}
src.push('1');
for _ in 0..5000 {
src.push(']');
}
let r = sml::parse(&src);
assert!(r.is_err(), "5000 层嵌套数组应被深度守卫拒绝而非栈溢出");
}
#[test]
fn nan_inf_serialization_is_roundtrip_safe() {
use sml::Value::*;
let cases = vec![
Float(f64::NAN),
Float(f64::INFINITY),
Float(f64::NEG_INFINITY),
];
for f in &cases {
let out = sml::to_sml(f);
assert!(
!out.trim().eq_ignore_ascii_case("nan")
&& !out.trim().eq_ignore_ascii_case("inf")
&& !out.trim().eq_ignore_ascii_case("-inf"),
"to_sml 输出裸非有限字面量: {:?} -> {}",
f,
out
);
let reparsed = sml::parse(&format!("x: {}", out));
assert!(reparsed.is_ok(), "to_sml 输出不可回读: {}", out);
}
let v = Float(f64::NAN);
let md = to_markdown(&v, &MarkdownOptions::new()).unwrap();
assert!(md.contains("nan"), "markdown 应渲染 nan: {md}");
}