From 9ddaff2dae35f47e2d4e277de539574db91f25a3 Mon Sep 17 00:00:00 2001 From: loganj <77709487+g1obalworlds@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:41:41 +0000 Subject: [PATCH] Import: hostile Markdown and HTML no longer take quadratic time (#355) A few hundred KB of `*[`, `![a](b`, ``) and `&` (for `;`). Bracket and destination scans now share a per-paragraph look-ahead budget, tags are searched within 4 KB and entity names within 33 bytes. - HTML `unescape` searched the whole remaining text for `;` after every stray `&`; it now looks only at the first 33 bytes. - The Markdown/HTML-to-document step appended every inline through `insert_text`, which rescans all earlier runs, so a paragraph with thousands of runs took quadratic time on its own. It now appends runs directly and normalises once. Ordinary links, images, tags and entities read as before. Co-authored-by: Claude Sonnet 5.5 --- crates/formats/src/html.rs | 7 +++++- crates/formats/src/markdown.rs | 37 ++++++++++++++++++++++++----- crates/formats/src/model.rs | 43 ++++++++++++++++++++++++---------- crates/formats/src/tests.rs | 36 ++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 19 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index c6fefdc..91d6d83 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -143,7 +143,12 @@ pub fn unescape(s: &str) -> String { while let Some(k) = rest.find('&') { out.push_str(&rest[..k]); let after = &rest[k + 1..]; - let end = after.find(';').filter(|e| *e <= 32); + // An entity name is short; looking further would rescan the whole text for every stray `&`. + let mut cut = after.len().min(33); + while !after.is_char_boundary(cut) { + cut -= 1; + } + let end = after.get(..cut).and_then(|w| w.find(';')); match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) { Some((t, e)) => { out.push_str(&t); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 79e332b..7616391 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -542,12 +542,16 @@ fn push_text(nodes: &mut Vec, s: &str, f: &Fmt) { nodes.push(Node::Text(s.to_string(), f.clone())); } -/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected). -fn matching_bracket(s: &str, open: usize) -> Option { +/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected). Gives up +/// after looking `max` bytes ahead. +fn matching_bracket(s: &str, open: usize, max: usize) -> Option { let b = s.as_bytes(); let mut depth = 0usize; let mut i = open; while let Some(&c) = b.get(i) { + if i - open > max { + return None; + } match c { b'\\' => i += 1, b'`' => { @@ -687,6 +691,10 @@ fn tag_attr(tag: &str, name: &str) -> Option { fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec { let mut nodes: Vec = Vec::new(); + // Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(` + // would otherwise rescan the rest of the text, which is quadratic on hostile input; once the + // budget is gone, further brackets are plain text. + let mut scan_budget = s.len().saturating_mul(8).saturating_add(8192); let b = s.as_bytes(); let mut i = 0usize; let mut text_start = 0usize; @@ -758,8 +766,12 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec { b'!' | b'[' if depth < MAX_NEST => { let img = c == b'!'; let open = if img { i + 1 } else { i }; - let parsed = if b.get(open) == Some(&b'[') { - matching_bracket(s, open).and_then(|close| { + let parsed = if b.get(open) == Some(&b'[') && scan_budget > 0 { + let found = matching_bracket(s, open, scan_budget); + scan_budget = scan_budget.saturating_sub(found.map_or(scan_budget, |close| close - open)); + found.and_then(|close| { + // A destination that does not parse may have been read to the end of the text. + scan_budget = scan_budget.saturating_sub(s.len().saturating_sub(close)); let (dest, end) = link_dest(s, close + 1)?; Some((s.get(open + 1..close)?.to_string(), dest, end)) }) @@ -795,7 +807,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec { } b'<' => { let rest = s.get(i + 1..).unwrap_or(""); - let end = rest.find('>'); + let end = within(rest, MAX_TAG).find('>'); let inner = end.and_then(|e| rest.get(..e)).unwrap_or(""); let lower = inner.to_ascii_lowercase(); let is_auto = !inner.is_empty() @@ -826,7 +838,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec { } b'&' => { let rest = s.get(i..).unwrap_or(""); - match rest.find(';').filter(|k| *k <= 32).and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) { + match within(rest, 33).find(';').and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) { Some((ch, k)) => { flush!(); push_text(&mut nodes, &ch, base); @@ -843,6 +855,19 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec { nodes } +/// Longest `<...>` tag or autolink taken as inline HTML. +const MAX_TAG: usize = 4096; + +/// The first `n` bytes of `s` (fewer if that would cut a character), so a search for a closing +/// character stays short however long the text is. +fn within(s: &str, n: usize) -> &str { + let mut end = n.min(s.len()); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or("") +} + fn emph_index(ch: char) -> usize { match ch { '*' => 0, diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index a377428..0ebd31b 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for}; -use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK}; +use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, Run}; use wordcraft_doc::props::{ Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, RowProps, TextColor, Underline, VMerge, VertAlign, }; @@ -321,6 +321,26 @@ fn rule_borders() -> Borders { Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.75, color: Some(Rgb(0xA0, 0xA0, 0xA0)), space: 1.0 }), ..Default::default() } } +/// Append text (without object characters) or one inline object to the end of `p`. +fn append(p: &mut Paragraph, text: &str, props: &CharProps, obj: Option) { + let text: std::borrow::Cow = if text.contains(OBJ) { text.chars().filter(|c| *c != OBJ).collect::().into() } else { text.into() }; + let piece = match obj { + Some(o) => { + p.objects.push(o); + OBJ.to_string() + } + None => text.into_owned(), + }; + if piece.is_empty() { + return; + } + p.text.push_str(&piece); + match p.runs.last_mut() { + Some(last) if last.props == *props => last.len += piece.len(), + _ => p.runs.push(Run { len: piece.len(), props: props.clone() }), + } +} + struct Builder<'a> { doc: &'a mut Document, } @@ -349,8 +369,9 @@ impl Builder<'_> { if p.page_break { out.props.page_break_before = Some(true); } + // Inlines are appended in order, so build the runs directly: inserting each one through + // `Paragraph::insert_text` rescans every earlier run and is quadratic in their number. for i in &p.inlines { - let end = out.len(); match i { Inline::Text(t, f) => { let t = clean_text(t); @@ -358,32 +379,30 @@ impl Builder<'_> { if p.kind == Kind::Code && cp.font.as_deref() == Some(MONO_FONT) { cp.font = None; } - let _ = out.insert_text(end, &t, &cp); + append(&mut out, &t, &cp, None); } Inline::Image(img) => { let key = self.doc.add_media(img.data.to_vec(), &img.ext); let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0)); let obj = InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: Default::default(), crop: [0.0; 4] }; - let _ = out.insert_object(end, obj, &CharProps::default()); + append(&mut out, "", &CharProps::default(), Some(obj)); } Inline::Anchor(name) => { - let _ = out.insert_object(end, InlineObject::BookmarkStart { name: name.clone() }, &CharProps::default()); - let e2 = out.len(); - let _ = out.insert_object(e2, InlineObject::BookmarkEnd { name: name.clone() }, &CharProps::default()); + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkStart { name: name.clone() })); + append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); } Inline::Figure(_) => {} Inline::Equation { linear, display } => { let linear = clean_text(linear); if !linear.is_empty() { - let _ = out.insert_object( - end, - InlineObject::Equation { linear, display: *display, math: Default::default() }, - &CharProps::default(), - ); + let obj = InlineObject::Equation { linear, display: *display, math: Default::default() }; + append(&mut out, "", &CharProps::default(), Some(obj)); } } } } + out.normalize(); + out.touch(); out } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 0443882..546d97b 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -554,3 +554,39 @@ fn chart_alt_text_is_an_image_alt_never_body_text() { } assert_eq!(para_texts(&d), ["See below"]); } + +/// Hostile Markdown and HTML that rescanned the rest of the text from every `[`, `![`, `<` or `&` +/// took quadratic time: a few hundred KB froze the importer for tens of seconds. +#[test] +fn hostile_importer_input_takes_linear_time() { + use std::time::{Duration, Instant}; + let cases: [(&str, String); 5] = [ + ("md", "*[".repeat(50_000)), + ("md", "![a](b".repeat(100_000)), + ("md", "[a](".repeat(100_000)), + ("md", "bold .\n").unwrap().unwrap(); + let text = md.plain_text(Default::default()); + assert!(text.contains("the docs"), "{text}"); + assert!(text.contains("& &"), "{text}"); + let html = import("html", "

<&Aé &nosuchentity; A

".as_bytes()).unwrap().unwrap(); + assert_eq!(html.plain_text(Default::default()).trim(), "<&AĆ© &nosuchentity; A"); + // An entity name longer than the scan window is left alone, as before. + let long = format!("

&{};

", "a".repeat(40)); + let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default()); + assert!(t.contains(&"a".repeat(40)), "{t}"); +}