feat(markdown): support rendering and sanitizing runnable pre code-blocks
- markdown.rs: CodeBlock 处理识别 `runnable` 标记 + 受支持语言,在 <pre> 上 挂 data-runnable/data-lang/data-overrides;overrides JSON 经 escape_html 转义 防属性注入;非可运行围栏的 language-xxx 改取纯语言 token(首个空白前) - sanitizer.rs: clean_html 放行 <pre> 的 data-runnable/data-lang/data-overrides - 新增 4 个 markdown 测试 + 2 个 sanitizer 测试覆盖转义/白名单/字段顺序
This commit is contained in:
parent
9fe128d44d
commit
a77a2e3502
@ -90,6 +90,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
let mut in_heading = false;
|
let mut in_heading = false;
|
||||||
let mut in_codeblock = false;
|
let mut in_codeblock = false;
|
||||||
let mut code_lang: Option<String> = None;
|
let mut code_lang: Option<String> = None;
|
||||||
|
/// 可运行代码块的 (lang, html-escaped overrides JSON);为 None 表示普通代码块。
|
||||||
|
let mut code_runnable: Option<(String, String)> = None;
|
||||||
let mut code_buffer = String::new();
|
let mut code_buffer = String::new();
|
||||||
let mut non_heading_events: Vec<Event> = Vec::new();
|
let mut non_heading_events: Vec<Event> = Vec::new();
|
||||||
|
|
||||||
@ -151,6 +153,25 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
// 解析围栏 info:识别 `runnable` 标记与可选 ResourceLimits JSON 覆盖。
|
||||||
|
// 仅在「标记为 runnable 且语言受支持」时挂 data-*,供阅读器扫描挂载运行器。
|
||||||
|
code_runnable = code_lang
|
||||||
|
.as_deref()
|
||||||
|
.map(|info| {
|
||||||
|
let (lang, runnable, overrides) =
|
||||||
|
crate::api::code_runner::languages::parse_fence_info(info);
|
||||||
|
if runnable && crate::api::code_runner::languages::is_supported_lang(&lang) {
|
||||||
|
// overrides 序列化为 JSON 后 HTML 转义,避免属性注入。
|
||||||
|
let ov_json = overrides
|
||||||
|
.map(|o| serde_json::to_string(&o).unwrap_or_default())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let escaped = crate::utils::html::escape_html(&ov_json);
|
||||||
|
Some((lang, escaped))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
code_buffer.clear();
|
code_buffer.clear();
|
||||||
}
|
}
|
||||||
Event::Text(text) if in_codeblock => {
|
Event::Text(text) if in_codeblock => {
|
||||||
@ -160,11 +181,24 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
|||||||
// 使用 syntect 对代码块进行服务端语法高亮。
|
// 使用 syntect 对代码块进行服务端语法高亮。
|
||||||
let highlighted =
|
let highlighted =
|
||||||
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
|
||||||
html.push_str("<pre><code");
|
// 可运行代码块:在 <pre> 上挂 data-runnable / data-lang / data-overrides,
|
||||||
if let Some(lang) = &code_lang {
|
// 阅读器(post_content.rs)客户端扫描这些标记原地挂载 CodeRunner 组件。
|
||||||
html.push_str(&format!(" class=\"language-{lang}\""));
|
if let Some((lang, overrides_escaped)) = code_runnable.take() {
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<pre data-runnable="true" data-lang="{lang}" data-overrides="{overrides_escaped}"><code class="language-{lang}">"#
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
html.push_str("<pre><code");
|
||||||
|
if let Some(lang) = &code_lang {
|
||||||
|
// 围栏语言可能含 info 修饰(如 `python runnable {...}`),
|
||||||
|
// 高亮的 language-xxx 取纯语言 token(首个空白前)。
|
||||||
|
let clean_lang = lang.split_whitespace().next().unwrap_or("");
|
||||||
|
if !clean_lang.is_empty() {
|
||||||
|
html.push_str(&format!(" class=\"language-{clean_lang}\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push('>');
|
||||||
}
|
}
|
||||||
html.push('>');
|
|
||||||
html.push_str(&highlighted);
|
html.push_str(&highlighted);
|
||||||
html.push_str("</code></pre>");
|
html.push_str("</code></pre>");
|
||||||
in_codeblock = false;
|
in_codeblock = false;
|
||||||
@ -631,6 +665,76 @@ mod tests {
|
|||||||
assert!(result.html.contains("plain text"));
|
assert!(result.html.contains("plain text"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_runnable_block_emits_data_attrs() {
|
||||||
|
// `python runnable` 围栏:pre 上挂 data-runnable / data-lang / data-overrides,
|
||||||
|
// 阅读器据此原地挂载 CodeRunner 组件。
|
||||||
|
let result = render_markdown_enhanced("```python runnable\nprint('hi')\n```");
|
||||||
|
assert!(
|
||||||
|
result.html.contains(r#"data-runnable="true""#),
|
||||||
|
"应输出 data-runnable, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.html.contains(r#"data-lang="python""#),
|
||||||
|
"应输出 data-lang=python, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
// 无 overrides 时 data-overrides 为空。
|
||||||
|
assert!(
|
||||||
|
result.html.contains(r#"data-overrides="""#),
|
||||||
|
"无 overrides 时 data-overrides 应为空, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
// 内部仍带高亮 code 与 language-python。
|
||||||
|
assert!(
|
||||||
|
result.html.contains(r#"<code class="language-python">"#),
|
||||||
|
"应保留高亮 code, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_runnable_block_with_overrides() {
|
||||||
|
let result = render_markdown_enhanced(
|
||||||
|
r#"```node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":false,"cpu_cores":1.0,"output_bytes":1024}
|
||||||
|
console.log(1)
|
||||||
|
```"#,
|
||||||
|
);
|
||||||
|
assert!(result.html.contains(r#"data-lang="node""#));
|
||||||
|
// overrides JSON 应被 HTML 转义后放入属性(双引号变 "),不得出现裸引号越界。
|
||||||
|
// 字段顺序按 serde 派生默认(字母序),cpu_cores 在前。
|
||||||
|
assert!(
|
||||||
|
result.html.contains("data-overrides=\"{"cpu_cores":1.0"),
|
||||||
|
"overrides 应 HTML 转义, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
// 安全:不得出现可越出属性边界的裸双引号 JSON。
|
||||||
|
assert!(
|
||||||
|
!result.html.contains(r#"data-overrides="{"timeout""#),
|
||||||
|
"overrides 裸引号越界, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
|
||||||
|
// 语言不在白名单:runnable 标记被忽略,输出普通代码块。
|
||||||
|
let result = render_markdown_enhanced("```rust runnable\nfn main(){}\n```");
|
||||||
|
assert!(
|
||||||
|
!result.html.contains("data-runnable"),
|
||||||
|
"不支持的语言不应挂 data-runnable, got: {}",
|
||||||
|
result.html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_plain_fence_without_runnable_no_data_attrs() {
|
||||||
|
let result = render_markdown_enhanced("```python\nprint(1)\n```");
|
||||||
|
assert!(!result.html.contains("data-runnable"));
|
||||||
|
assert!(result.html.contains(r#"<pre><code class="language-python">"#));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_markdown_code_block_preserves_html_content() {
|
fn render_markdown_code_block_preserves_html_content() {
|
||||||
let result = render_markdown_enhanced("```html\n<script>alert(1)</script>\n```");
|
let result = render_markdown_enhanced("```html\n<script>alert(1)</script>\n```");
|
||||||
|
|||||||
@ -372,6 +372,12 @@ pub fn clean_html(input: &str) -> String {
|
|||||||
("img", vec!["data-src", "class", "style"]),
|
("img", vec!["data-src", "class", "style"]),
|
||||||
// input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
|
// input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
|
||||||
("input", vec!["type", "checked", "disabled"]),
|
("input", vec!["type", "checked", "disabled"]),
|
||||||
|
// pre 上的可运行代码块标记:data-runnable / data-lang / data-overrides。
|
||||||
|
// data-overrides 是 markdown 渲染时 HTML 转义后的 JSON(见 markdown.rs),不含未转义引号。
|
||||||
|
(
|
||||||
|
"pre",
|
||||||
|
vec!["data-runnable", "data-lang", "data-overrides"],
|
||||||
|
),
|
||||||
("span", vec!["class", "style"]),
|
("span", vec!["class", "style"]),
|
||||||
("h1", vec!["id", "class"]),
|
("h1", vec!["id", "class"]),
|
||||||
("h2", vec!["id", "class"]),
|
("h2", vec!["id", "class"]),
|
||||||
@ -671,4 +677,35 @@ mod tests {
|
|||||||
"评论侧 input 应被剥离, got: {result}"
|
"评论侧 input 应被剥离, got: {result}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_html_preserves_runnable_pre_data_attrs() {
|
||||||
|
// 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。
|
||||||
|
let input = r#"<pre data-runnable="true" data-lang="python" data-overrides="{"timeout_secs":10}"><code class="language-python">print(1)</code></pre>"#;
|
||||||
|
let result = clean_html(input);
|
||||||
|
assert!(
|
||||||
|
result.contains(r#"data-runnable="true""#),
|
||||||
|
"data-runnable 应保留, got: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains(r#"data-lang="python""#),
|
||||||
|
"data-lang 应保留, got: {result}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("data-overrides="),
|
||||||
|
"data-overrides 应保留, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_html_strips_unknown_data_attrs_on_pre() {
|
||||||
|
// 仅放行白名单的三个 data-* 属性,其它 data-*(如恶意 data-onclick)应被剥离。
|
||||||
|
let input = r#"<pre data-runnable="true" data-evil="x"><code>x</code></pre>"#;
|
||||||
|
let result = clean_html(input);
|
||||||
|
assert!(result.contains("data-runnable"), "白名单 data-runnable 应保留");
|
||||||
|
assert!(
|
||||||
|
!result.contains("data-evil"),
|
||||||
|
"未知 data-* 应被剥离, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user