diff --git a/CHANGELOG.md b/CHANGELOG.md index d5743df..1f7efdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). below each term, preserving code blocks without requiring a definition-list extension. Nested lists beginning with an empty item are separated from preceding text. Hard breaks inside headings and link labels are flattened so they cannot close the - container before later inline code. + container before later inline code. Redundant nested bold, italic and + strikethrough wrappers no longer emit delimiters that close their outer span, + and adjacent bracketed nodes cannot combine with following text into a link. - 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/render_ir/footnote-link-adjacency.bin b/crates/markkit/fuzz/seeds/render_ir/footnote-link-adjacency.bin new file mode 100644 index 0000000..ef240c1 Binary files /dev/null and b/crates/markkit/fuzz/seeds/render_ir/footnote-link-adjacency.bin differ diff --git a/crates/markkit/fuzz/seeds/render_ir/repeated-bold-inline-block.bin b/crates/markkit/fuzz/seeds/render_ir/repeated-bold-inline-block.bin new file mode 100644 index 0000000..044025a Binary files /dev/null and b/crates/markkit/fuzz/seeds/render_ir/repeated-bold-inline-block.bin differ diff --git a/crates/markkit/src/render/markdown.rs b/crates/markkit/src/render/markdown.rs index eee091f..578c855 100644 --- a/crates/markkit/src/render/markdown.rs +++ b/crates/markkit/src/render/markdown.rs @@ -30,6 +30,9 @@ const MAX_TABLE_GRID_CELLS: usize = 1_000_000; /// Span clamps applied at layout time (HTML limits; IR may come from anywhere). const MAX_COLSPAN: usize = 1000; const MAX_ROWSPAN: usize = 65534; +const WRAP_BOLD: u8 = 1; +const WRAP_ITALIC: u8 = 2; +const WRAP_STRIKETHROUGH: u8 = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Trip { @@ -53,6 +56,9 @@ pub struct MarkdownRenderer { in_link: bool, /// Rendering inline content that must stay on one Markdown line. single_line: bool, + /// Active emphasis delimiters. Repeating the same delimiter would close the + /// outer span, so redundant nested wrappers are omitted. + active_wrappers: u8, } impl MarkdownRenderer { @@ -66,6 +72,7 @@ impl MarkdownRenderer { in_table: false, in_link: false, single_line: false, + active_wrappers: 0, } } @@ -76,6 +83,7 @@ impl MarkdownRenderer { self.in_table = false; self.in_link = false; self.single_line = false; + self.active_wrappers = 0; self.render_nodes(&doc.nodes, 0); normalize_output(&self.buf) } @@ -463,11 +471,18 @@ impl MarkdownRenderer { } else { Position::Inline }; + // A footnote reference followed by a new text node beginning + // with `(` would combine into an inline link (`[^x](...)`). + if self.buf.ends_with(']') && t.starts_with('(') { + self.buf.push('\\'); + } self.line_start = escape::push_text(&mut self.buf, t, pos); } - Node::Bold { children } => self.render_wrapped("**", children), - Node::Italic { children } => self.render_wrapped("*", children), - Node::Strikethrough { children } => self.render_wrapped("~~", children), + Node::Bold { children } => self.render_wrapped("**", WRAP_BOLD, children), + Node::Italic { children } => self.render_wrapped("*", WRAP_ITALIC, children), + Node::Strikethrough { children } => { + self.render_wrapped("~~", WRAP_STRIKETHROUGH, children); + } Node::InlineCode(code) => { escape::push_code_span(&mut self.buf, code, self.in_table); self.line_start = false; @@ -553,11 +568,17 @@ impl MarkdownRenderer { } } - fn render_wrapped(&mut self, delim: &str, children: &[Node]) { + fn render_wrapped(&mut self, delim: &str, wrapper: u8, children: &[Node]) { + if self.active_wrappers & wrapper != 0 { + self.render_inline(children); + return; + } + self.active_wrappers |= wrapper; self.buf.push_str(delim); self.line_start = false; self.render_inline(children); self.buf.push_str(delim); + self.active_wrappers &= !wrapper; } /// Render a block node as single-line inline content. diff --git a/crates/markkit/tests/fixtures/html/malformed.md b/crates/markkit/tests/fixtures/html/malformed.md index 2161df6..ee23a3a 100644 --- a/crates/markkit/tests/fixtures/html/malformed.md +++ b/crates/markkit/tests/fixtures/html/malformed.md @@ -4,4 +4,4 @@ Next paragraph Mismatched **bold with no close** -**Entities: & \< > " ' Nested *italic **and bold***** crossed** ** +**Entities: & \< > " ' Nested *italic and bold* crossed ** diff --git a/crates/markkit/tests/output_safety.rs b/crates/markkit/tests/output_safety.rs index d1800c2..aabcbc8 100644 --- a/crates/markkit/tests/output_safety.rs +++ b/crates/markkit/tests/output_safety.rs @@ -617,6 +617,73 @@ fn blockquote_heading_inline_blocks_cannot_expose_html() { assert!(visible_text(&md).contains("<< ` diff --git a/knowledge/log.md b/knowledge/log.md index ecadd9f..c5e6d1e 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,7 +2,7 @@ ## 2026-10-31 -* **Container fence safety**: task checkboxes render on their own line; definition bodies use ordinary GFM list items below their term. This prevents markers from consuming code fences and exposing raw HTML, including empty leading paragraphs and nested definitions. A nested list with an empty first item also requires a blank line before it; the renderer checks actual output once, within the remaining output budget. Headings, link labels and block nodes flattened into inline content now collapse hard breaks to spaces, preventing a break from closing the container before later inline code. Exact nightly crash seeds and content/grouping regressions cover these paths. See [Threat Model](security/threat-model.md) T1 and [IR Specification](foundations/ir.md). +* **Container fence safety**: task checkboxes render on their own line; definition bodies use ordinary GFM list items below their term. This prevents markers from consuming code fences and exposing raw HTML, including empty leading paragraphs and nested definitions. A nested list with an empty first item also requires a blank line before it; the renderer checks actual output once, within the remaining output budget. Headings, link labels and block nodes flattened into inline content collapse hard breaks to spaces, preventing a break from closing the container before later inline code. Redundant nested bold, italic and strikethrough wrappers render once so an inner delimiter cannot close its outer span. A bracketed node followed by text beginning with `(` is separated so it cannot form an unintended inline link. Exact nightly crash seeds and content/grouping regressions cover these paths. See [Threat Model](security/threat-model.md) T1 and [IR Specification](foundations/ir.md). ## 2026-10-28 diff --git a/knowledge/security/fuzzing.md b/knowledge/security/fuzzing.md index b40571b..fffc631 100644 --- a/knowledge/security/fuzzing.md +++ b/knowledge/security/fuzzing.md @@ -82,7 +82,10 @@ the Actions cache; crashes upload as `fuzz-crashes-` artifacts. links, adjacent code spans merging, and code fences consumed as paragraph text after task-checkbox, nested definition markers, or a nested ordered list with an empty first item. A hard break inside a link label nested in a heading - also closed both containers before later inline code. Exact crash seeds for + also closed both containers before later inline code; repeating a bold wrapper + inside bold closed the outer span with the same result. A footnote reference + followed by text beginning with `(` could also combine into an inline link and + consume later delimiters. Exact crash seeds for these cases are retained in `fuzz/seeds/render_ir/` and `fuzz/seeds/markdown/`. - XLS (calamine 0.37): unchecked reads in FILEPASS, CODEPAGE, DATEMODE, BOF, BOUNDSHEET, LBL (incl. defined-name tokens), EXTERNSHEET, SST (cRun / diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 0ca9ce8..45a4b02 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -58,8 +58,9 @@ that renders as headings, links, code fences or tables). inside headings and link labels; list item bodies are indented by the marker width (`1. ` = 3, `10. ` = 4); a block always starts on its own line after a blank line; hard breaks never produce a blank line; - nested links render as text; empty destinations are written `<>`; link titles - escape `"`, `\` and `<`. + nested links render as text; redundant nested emphasis wrappers render once; + adjacent bracketed nodes and opening parentheses are separated; empty + destinations are written `<>`; link titles escape `"`, `\` and `<`. - Task checkboxes occupy their own line before the item body so they cannot turn a leading code fence into paragraph text. Definition lists render as a term paragraph and ordinary GFM list items, keeping nested blocks safe