From a77a2e35028ec4d3c1683fb131b980327a6b9117 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 3 Jul 2026 18:19:26 +0800 Subject: [PATCH] feat(markdown): support rendering and sanitizing runnable pre code-blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown.rs: CodeBlock 处理识别 `runnable` 标记 + 受支持语言,在
 上
  挂 data-runnable/data-lang/data-overrides;overrides JSON 经 escape_html 转义
  防属性注入;非可运行围栏的 language-xxx 改取纯语言 token(首个空白前)
- sanitizer.rs: clean_html 放行 
 的 data-runnable/data-lang/data-overrides
- 新增 4 个 markdown 测试 + 2 个 sanitizer 测试覆盖转义/白名单/字段顺序
---
 src/api/markdown.rs  | 112 +++++++++++++++++++++++++++++++++++++++++--
 src/api/sanitizer.rs |  37 ++++++++++++++
 2 files changed, 145 insertions(+), 4 deletions(-)

diff --git a/src/api/markdown.rs b/src/api/markdown.rs
index 0a1f5cb..d5ddfe2 100644
--- a/src/api/markdown.rs
+++ b/src/api/markdown.rs
@@ -90,6 +90,8 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
     let mut in_heading = false;
     let mut in_codeblock = false;
     let mut code_lang: Option = None;
+    /// 可运行代码块的 (lang, html-escaped overrides JSON);为 None 表示普通代码块。
+    let mut code_runnable: Option<(String, String)> = None;
     let mut code_buffer = String::new();
     let mut non_heading_events: Vec = Vec::new();
 
@@ -151,6 +153,25 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
                     }
                     _ => 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();
             }
             Event::Text(text) if in_codeblock => {
@@ -160,11 +181,24 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
                 // 使用 syntect 对代码块进行服务端语法高亮。
                 let highlighted =
                     crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
-                html.push_str("
 上挂 data-runnable / data-lang / data-overrides,
+                // 阅读器(post_content.rs)客户端扫描这些标记原地挂载 CodeRunner 组件。
+                if let Some((lang, overrides_escaped)) = code_runnable.take() {
+                    html.push_str(&format!(
+                        r#"
"#
+                    ));
+                } else {
+                    html.push_str("
');
                 }
-                html.push('>');
                 html.push_str(&highlighted);
                 html.push_str("
"); in_codeblock = false; @@ -631,6 +665,76 @@ mod tests { 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, 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#"
"#));
+    }
+
     #[test]
     fn render_markdown_code_block_preserves_html_content() {
         let result = render_markdown_enhanced("```html\n\n```");
diff --git a/src/api/sanitizer.rs b/src/api/sanitizer.rs
index d896985..6d76c6c 100644
--- a/src/api/sanitizer.rs
+++ b/src/api/sanitizer.rs
@@ -372,6 +372,12 @@ pub fn clean_html(input: &str) -> String {
             ("img", vec!["data-src", "class", "style"]),
             // input 仅放行 checkbox 必备属性;type 的具体取值由 element_handler 强校验为 checkbox。
             ("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"]),
             ("h1", vec!["id", "class"]),
             ("h2", vec!["id", "class"]),
@@ -671,4 +677,35 @@ mod tests {
             "评论侧 input 应被剥离, got: {result}"
         );
     }
+
+    #[test]
+    fn clean_html_preserves_runnable_pre_data_attrs() {
+        // 可运行代码块标记应完整保留,供阅读器扫描挂载 CodeRunner。
+        let input = r#"
print(1)
"#; + 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#"
x
"#; + let result = clean_html(input); + assert!(result.contains("data-runnable"), "白名单 data-runnable 应保留"); + assert!( + !result.contains("data-evil"), + "未知 data-* 应被剥离, got: {result}" + ); + } }