From 4391ab17319f8b5b8c4f55774d84351b5e92d147 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 30 Oct 2026 21:10:33 -0500 Subject: [PATCH 1/3] fix(render): keep code blocks safe inside list containers --- CHANGELOG.md | 4 + .../seeds/markdown/nested-definition-code.md | Bin 0 -> 67 bytes .../seeds/render_ir/task-leading-code.bin | Bin 0 -> 72 bytes crates/markkit/src/markdown.rs | 5 +- crates/markkit/src/render/markdown.rs | 55 ++++-- crates/markkit/tests/html_integration.rs | 8 +- crates/markkit/tests/output_safety.rs | 162 +++++++++++++++++- docs/getting-started.md | 7 + knowledge/formats/html-extraction.md | 2 +- knowledge/foundations/ir.md | 3 +- knowledge/log.md | 4 + knowledge/security/fuzzing.md | 4 +- knowledge/security/threat-model.md | 4 + 13 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 crates/markkit/fuzz/seeds/markdown/nested-definition-code.md create mode 100644 crates/markkit/fuzz/seeds/render_ir/task-leading-code.bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b98d58..cba910a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- Prevent raw HTML escaping code blocks inside task lists and nested definitions. + Task bodies now start below their checkbox; definitions render as GFM lists + below each term, preserving code blocks without requiring a definition-list extension. + - PDF stream guards honor escaped dictionary keys and bound compressed prefixes before image filters, including when OCR is disabled. diff --git a/crates/markkit/fuzz/seeds/markdown/nested-definition-code.md b/crates/markkit/fuzz/seeds/markdown/nested-definition-code.md new file mode 100644 index 0000000000000000000000000000000000000000..5988df7ac38f0600a7d234e761cfdc23affeb0ed GIT binary patch literal 67 zcmZ?Zva(`W$fclQmB0W52?+`R|Npl+ukp*~J%f#%)_JvG_F6VoKn9WsP>Poqq+FE= Gqz3>1niWm} literal 0 HcmV?d00001 diff --git a/crates/markkit/fuzz/seeds/render_ir/task-leading-code.bin b/crates/markkit/fuzz/seeds/render_ir/task-leading-code.bin new file mode 100644 index 0000000000000000000000000000000000000000..fad85e3b2c17f8efdcd8f3af26628b01122d36dc GIT binary patch literal 72 vcmcbqP* { - self.render_list(*ordered, *start, items); + self.render_list( + *ordered, + *start, + items + .iter() + .map(|item| (item.checked, item.children.as_slice())), + ); } Node::BlockQuote { children, .. } => { @@ -234,7 +240,7 @@ impl MarkdownRenderer { self.buf.push_str("---\n"); } - Node::DefinitionList { items, .. } => self.render_definition_list(items, depth), + Node::DefinitionList { items, .. } => self.render_definition_list(items), Node::Figure { children, caption, .. @@ -297,8 +303,9 @@ impl MarkdownRenderer { } } - /// Render a definition list: `Term` line, then `: definition` per definition. - fn render_definition_list(&mut self, items: &[DefinitionItem], depth: usize) { + /// Use GFM lists for definitions so nested blocks stay structured in readers + /// without the definition-list extension (S17). + fn render_definition_list(&mut self, items: &[DefinitionItem]) { for (i, item) in items.iter().enumerate() { if self.over_limit() { return; @@ -310,20 +317,25 @@ impl MarkdownRenderer { self.line_start = true; self.render_inline(&item.term); trim_trailing_inline(&mut self.buf, start); - self.buf.push('\n'); - for def in &item.definitions { - let inner = self.render_blocks(def, depth); - self.buf.push(':'); - push_hanging(&mut self.buf, &inner, " ", " "); - } + self.buf.push_str("\n\n"); + self.render_list( + false, + None, + item.definitions.iter().map(|def| (None, def.as_slice())), + ); } } /// Render a list. Each item body is rendered on its own and indented by /// the marker width, so nested blocks stay inside the item for any marker. - fn render_list(&mut self, ordered: bool, start: Option, items: &[ListItem]) { + fn render_list<'a>( + &mut self, + ordered: bool, + start: Option, + items: impl IntoIterator, &'a [Node])>, + ) { let start_num = start.unwrap_or(1); - for (i, item) in items.iter().enumerate() { + for (i, (checked, children)) in items.into_iter().enumerate() { if self.over_limit() { return; } @@ -335,17 +347,22 @@ impl MarkdownRenderer { let pad = " ".repeat(marker.len()); let body = { let mut sub = self.sub(); - sub.render_item_children(&item.children); + sub.render_item_children(children); self.absorb(&sub); normalize_output(&sub.buf) }; let mut lines = body.lines(); self.buf.push_str(&marker); - match item.checked { + match checked { Some(true) => self.buf.push_str("[x] "), Some(false) => self.buf.push_str("[ ] "), None => {} } + if checked.is_some() { + // The checkbox is a paragraph; a following block needs its own line. + self.buf.push('\n'); + self.buf.push_str(&pad); + } self.buf.push_str(lines.next().unwrap_or("")); self.buf.push('\n'); for line in lines { diff --git a/crates/markkit/tests/html_integration.rs b/crates/markkit/tests/html_integration.rs index c35f625..9fa5d84 100644 --- a/crates/markkit/tests/html_integration.rs +++ b/crates/markkit/tests/html_integration.rs @@ -765,13 +765,13 @@ fn task_list_github_markup() {
  • Todo
  • Plain
  • "#; - assert_eq!(convert(html), "- [x] Done\n- [ ] Todo\n- Plain\n"); + assert_eq!(convert(html), "- [x]\n Done\n- [ ]\n Todo\n- Plain\n"); } #[test] fn task_list_checkbox_inside_paragraph() { let html = r#"
    1. Ship it

    "#; - assert_eq!(convert(html), "1. [x] Ship it\n"); + assert_eq!(convert(html), "1. [x]\n Ship it\n"); } #[test] @@ -792,14 +792,14 @@ fn definition_list() {
    Go

    Another.

    Second para.

    "; assert_eq!( convert(html), - "Rust\n: A language.\n: Also a fungus.\n\nGo\n: Another.\n\n Second para.\n" + "Rust\n\n- A language.\n- Also a fungus.\n\nGo\n\n- Another.\n\n Second para.\n" ); } #[test] fn definition_list_div_wrappers_and_shared_terms() { let html = "
    A
    B
    Both
    "; - assert_eq!(convert(html), "A \nB\n: Both\n"); + assert_eq!(convert(html), "A \nB\n\n- Both\n"); } #[test] diff --git a/crates/markkit/tests/output_safety.rs b/crates/markkit/tests/output_safety.rs index feb0f60..46259a9 100644 --- a/crates/markkit/tests/output_safety.rs +++ b/crates/markkit/tests/output_safety.rs @@ -7,7 +7,7 @@ //! inspects the resulting events. use markkit::MarkdownRenderer; -use markkit::ir::{Document, ListItem, Node, TableCell}; +use markkit::ir::{DefinitionItem, Document, ListItem, Node, TableCell}; use proptest::prelude::*; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; @@ -615,6 +615,166 @@ fn inline_run_next_to_block_at_top_level() { assert!(visible_text(&md).contains("# tail"), "{md}"); } +#[test] +fn task_list_starting_with_code_preserves_block_and_checkbox() { + for checked in [true, false] { + for ordered in [true, false] { + let code = "\n"; + let md = render(vec![Node::List { + ordered, + start: Some(10), + items: vec![ListItem::task( + vec![ + para(vec![]), + Node::CodeBlock { + language: Some("html".into()), + code: code.into(), + source: None, + }, + ], + checked, + )], + source: None, + }]); + assert_safe(&md); + assert!( + events(&md) + .iter() + .any(|e| matches!(e, Event::TaskListMarker(value) if *value == checked)), + "{md}" + ); + assert!( + events(&md) + .iter() + .any(|e| matches!(e, Event::Start(Tag::CodeBlock(_)))), + "{md}" + ); + assert_eq!(visible_text(&md), code, "{md} {:?}", events(&md)); + } + } +} + +#[test] +fn task_list_leading_nested_blocks_keep_code_safe() { + let code = || Node::CodeBlock { + language: Some("