Import: hostile Markdown and HTML no longer take quadratic time (#355)

A few hundred KB of `*[`, `![a](b`, `<a` or `&#` froze the importer for tens of seconds:
- Markdown rescanned the rest of the text from every unmatched `[` / `![` (and for the
  link destination), `<` (for `>`) 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 <noreply@anthropic.com>
This commit is contained in:
loganj 2026-10-10 21:41:41 +00:00 committed by GitHub
parent e2d8551b7f
commit 9ddaff2dae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 104 additions and 19 deletions

View File

@ -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);

View File

@ -542,12 +542,16 @@ fn push_text(nodes: &mut Vec<Node>, 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<usize> {
/// 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<usize> {
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<String> {
fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec<Node> {
let mut nodes: Vec<Node> = 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<Node> {
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<Node> {
}
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<Node> {
}
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<Node> {
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,

View File

@ -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<InlineObject>) {
let text: std::borrow::Cow<str> = if text.contains(OBJ) { text.chars().filter(|c| *c != OBJ).collect::<String>().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
}

View File

@ -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", "<a".repeat(200_000)),
("html", "&#".repeat(500_000)),
];
for (ext, text) in cases {
let t = Instant::now();
let d = import(ext, text.as_bytes()).unwrap().unwrap();
let _ = d.plain_text(Default::default());
assert!(t.elapsed() < Duration::from_secs(5), "{ext} {:?}... took {:?}", &text[..8], t.elapsed());
}
}
/// The bounded scans must not change how ordinary links, images, tags and entities read.
#[test]
fn bounded_scans_keep_ordinary_markdown_and_entities() {
let md =
import("md", b"See [the *docs*](https://example.com/a(b) and ![alt](x.png) & &amp; <b>bold</b> <https://example.com>.\n").unwrap().unwrap();
let text = md.plain_text(Default::default());
assert!(text.contains("the docs"), "{text}");
assert!(text.contains("& &"), "{text}");
let html = import("html", "<p>&lt;&amp;&#65;&eacute; &nosuchentity; &#x41;</p>".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!("<p>&{};</p>", "a".repeat(40));
let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default());
assert!(t.contains(&"a".repeat(40)), "{t}");
}