diff --git a/CHANGELOG.md b/CHANGELOG.md index 614461f1d..bae2fa79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,7 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When `rw confluence render --out -` cannot parse the current page's XHTML piped on stdin, it no longer prints an error log line ahead of the XHTML on stdout, where it ended up in the published page. rw now writes all logs, including `rw serve --verbose`, to stderr. - A PlantUML `!include` now expands only on its own line. Before, rw also expanded its path inside a longer one (`a.iuml` within `!include a.iuml.bak`) or a commented-out `' !include`, and could drop a repeated include's indentation. - A `[[target\|text]]` wikilink, the form a GFM table needs, no longer leaves a `\` on the target, which broke the link. -- A list item holding only a link reference definition, such as `- [r]: http://x`, followed by an indented blank line no longer crashes rendering its page in `rw serve` and `@rwdocs/core`, or aborts `rw backstage publish` and `rw confluence render`. rw still prints a harmless `pulldown-cmark` panic message for such a page. +- A list item containing only a link reference definition followed by an indented blank line no longer crashes rw or prints a panic message. - In the viewer, a relative link to something other than a `.md` file, such as `[x](sibling)` or `[x](../api/)`, now opens the page relative to the current one, as the address bar shows. Before, the viewer loaded that path from the site root, and the embedded viewer passed the unresolved link to the host. - An embedded viewer mounted without `resolveSectionRefs` now opens a page's links into the mounted section under its `basePath`. Before, it handed the host's `onNavigate` the bare site path, such as `/api`, outside the docs. - An embedded viewer based at `/x/docs` now hands a link to `/x/docs-v2/page` to the host's `onNavigate` instead of opening a broken `/-v2/page` itself. @@ -254,6 +254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rw serve` now answers only requests addressed to `localhost`, a `*.localhost` name, or an IP address, so a website you visit can no longer read your docs or post comments through DNS rebinding. To use another name, such as `mylaptop.local` or a cloud IDE address, add it with `--allowed-host` or `[server].allowed_hosts`. See [Allowed hosts](docs/configuration.md#allowed-hosts). - A project-root `README.md` that is a symlink out of the project, such as to `/etc/hosts`, is no longer the homepage. Before, `rw serve` and `@rwdocs/core` served the target file, and `rw backstage publish` uploaded it. A `README.md` linked to a file inside the project still works. See [README.md as Homepage](docs/configuration.md#readmemd-as-homepage). - PlantUML `!include` now reads only files inside `include_dirs`. rw refuses an absolute path such as `!include /etc/hosts`, or a path leading outside through `..` or a symlink, with a warning. A symlink's target must be in an include directory: one inside the project, or one passed to `rw confluence render -I`. Before, it inlined the file into diagrams sent to Kroki and into `rw backstage publish` bundles. See [PlantUML Includes](docs/diagrams.md#plantuml-includes). +- Documents containing repeated wikilinks with empty labels, such as `[[page|]]`, no longer make rw repeat the following content exponentially, potentially exhausting memory. Comment rendering is unaffected. ## [0.1.36] - 2026-09-07 diff --git a/Cargo.lock b/Cargo.lock index 6bbf03078..739ef9ca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3789,9 +3789,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" dependencies = [ "bitflags 2.10.0", "memchr", diff --git a/Cargo.toml b/Cargo.toml index a8c9e104a..e845b821f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ hex = "0.4" ignore = "0.4" md-5 = "0.11" pretty_assertions = "1.4" -pulldown-cmark = { version = "0.13", default-features = false, features = ["simd"] } +pulldown-cmark = { version = "0.13.5", default-features = false, features = ["simd"] } rayon = "1.10" regex = "1.11" serde_json = "1" diff --git a/crates/rw-parser/src/cmark_events.rs b/crates/rw-parser/src/cmark_events.rs deleted file mode 100644 index a0d012309..000000000 --- a/crates/rw-parser/src/cmark_events.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! cmark's offset iterator, made to survive a panic in pulldown-cmark 0.13.4. - -use std::ops::Range; -use std::panic::{AssertUnwindSafe, catch_unwind}; - -use pulldown_cmark::{Event, OffsetIter, Options, Parser}; - -#[cfg(not(panic = "unwind"))] -compile_error!("rw-parser catches a pulldown-cmark panic, which needs `panic = \"unwind\"`"); - -/// [`OffsetIter`] that steps over cmark's empty tight paragraphs. -/// -/// A link reference definition alone in a tight list item, followed by a -/// whitespace-only line cmark reads as a lazy continuation (one indented four -/// columns past the item's content, say), leaves cmark an empty tight -/// paragraph. `OffsetIter::next` unwraps its missing first child and panics. -/// By then it has only moved its cursor into the paragraph, so the next call -/// pops back out and goes on as if the paragraph were not there. -/// -/// One retry, uncaught: such a paragraph is never followed by another, since -/// two sibling paragraphs need a blank line between them, which makes the list -/// loose. A second panic is some other bug. -/// -/// The catch can't tell this panic from another, so a cmark upgrade must -/// re-run this module's canary and sweep tests. -pub(crate) struct CmarkEvents<'a> { - inner: OffsetIter<'a>, - /// Set only for a source with a `]:`, which every reference definition - /// holds: the catch costs a few percent of a render. - guarded: bool, -} - -impl<'a> CmarkEvents<'a> { - pub(crate) fn new(markdown: &'a str, options: Options) -> Self { - Self { - inner: Parser::new_ext(markdown, options).into_offset_iter(), - guarded: markdown.contains("]:"), - } - } - - #[inline(never)] - fn next_guarded(&mut self) -> Option<(Event<'a>, Range)> { - catch_unwind(AssertUnwindSafe(|| self.inner.next())).unwrap_or_else(|_| self.inner.next()) - } -} - -impl<'a> Iterator for CmarkEvents<'a> { - type Item = (Event<'a>, Range); - - fn next(&mut self) -> Option { - if self.guarded { - return self.next_guarded(); - } - self.inner.next() - } -} - -#[cfg(test)] -mod tests { - use std::panic::{AssertUnwindSafe, catch_unwind}; - - use pretty_assertions::assert_eq; - use pulldown_cmark::{Options, Parser as CmarkParser}; - - use crate::parser::tests::debug_stream; - - /// The issue's two inputs, and the shortest known trigger. - const TRIGGERS: [&str; 3] = [ - "10. ![a](x.puml)\n > - [r]:\n x.puml\n ", - " \t:::note\n>1) [r]: x.puml 'T'\n ", - "- [r]: http://x\n \n- b\n", - ]; - - fn cmark_panics(markdown: &str) -> bool { - catch_unwind(|| { - CmarkParser::new_ext(markdown, Options::empty()) - .into_offset_iter() - .count() - }) - .is_err() - } - - /// Once this fails, cmark is fixed and `CmarkEvents` can go. - #[test] - fn cmark_still_panics_on_the_triggers() { - for markdown in TRIGGERS { - assert!(cmark_panics(markdown), "{markdown:?}"); - } - } - - #[test] - fn the_empty_paragraph_is_skipped_and_the_page_goes_on() { - assert_eq!( - debug_stream("- [r]: http://x\n \n- b\n\n[r]\n"), - [ - "Start(List(None))", - "Start(Item)", - "End(Item)", - "Start(Item)", - "Text(Borrowed(\"b\"))", - "End(Item)", - "End(List(false))", - "Start(Paragraph { lone_image: false })", - "Start(Link { kind: Other, dest_url: Borrowed(\"http://x\") })", - "Text(Borrowed(\"r\"))", - "End(Link)", - "End(Paragraph)", - ] - ); - } - - /// Variations on the trigger: containers, list markers, refdef forms and - /// line endings, and the whitespace-only line and what follows it. - /// - /// Kept small: under `RUST_BACKTRACE=1` every caught panic captures a - /// backtrace. - #[test] - fn no_variation_of_the_trigger_panics() { - let outers = ["", "> ", " > ", "- ", "1. a\n", " "]; - let markers = ["- ", "* ", "1. ", "10) ", "- ", "- - "]; - let refdefs = [ - "[r]: http://x\n", - "[r]:\n x.puml\r\n", - "[r]: x.puml 'T'\r", - "[r]: \r\n \"t\"\n", - ]; - let quotes = ["", ">"]; - let blanks = [ - "", - " ", - " ", - " ", - " ", - " ", - "\t", - " \t", - "\t\t", - "\x0c", - " \x0b", - ]; - let tails = [ - "", - "\n", - "\n- b\n", - "\n\n- b\n", - "\n c\n", - "\n[r]\n", - "\r\n- b", - "\r- b\r", - ]; - let mut triggers = 0; - for outer in outers { - for marker in markers { - for refdef in refdefs { - for quote in quotes { - for blank in blanks { - for tail in tails { - let markdown = - format!("{outer}{marker}{refdef}{quote}{blank}{tail}"); - triggers += usize::from(cmark_panics(&markdown)); - let parsed = - catch_unwind(AssertUnwindSafe(|| debug_stream(&markdown))); - assert!(parsed.is_ok(), "{markdown:?}"); - } - } - } - } - } - } - assert!(triggers > 5_000, "only {triggers} variations reach the bug"); - } -} diff --git a/crates/rw-parser/src/lib.rs b/crates/rw-parser/src/lib.rs index a8ae9c773..ac996e8f6 100644 --- a/crates/rw-parser/src/lib.rs +++ b/crates/rw-parser/src/lib.rs @@ -33,7 +33,6 @@ mod alert; mod attrs; -mod cmark_events; mod directive; mod event; mod frontmatter; diff --git a/crates/rw-parser/src/parser.rs b/crates/rw-parser/src/parser.rs index cea7734af..031c3aa6c 100644 --- a/crates/rw-parser/src/parser.rs +++ b/crates/rw-parser/src/parser.rs @@ -9,11 +9,10 @@ use std::iter::{Fuse, Peekable}; use std::ops::Range; use pulldown_cmark as cmark; -use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, Options}; +use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, OffsetIter, Options}; use crate::alert::AlertKind; use crate::attrs::{Attrs, parse_fence_info}; -use crate::cmark_events::CmarkEvents; use crate::directive::DirectiveArgs; use crate::directive::line::{ ContainerLine, Escapes, InlineMatch, has_text_after_name, parse_container_line, @@ -113,7 +112,7 @@ impl From for RunSegment { reason = "independent flags, not the states of one machine" )] pub struct Parser<'a> { - inner: Peekable>>, + inner: Peekable>>, source: &'a str, /// Source offset just past the last character judged escaped. escaped_until: usize, @@ -218,7 +217,8 @@ impl<'a> Parser<'a> { pub fn new(markdown: &'a str, wikilinks: bool, directives: bool) -> Self { let (frontmatter, markdown) = frontmatter::split(markdown); Self { - inner: CmarkEvents::new(markdown, cmark_options(wikilinks)) + inner: cmark::Parser::new_ext(markdown, cmark_options(wikilinks)) + .into_offset_iter() .fuse() .peekable(), source: markdown, @@ -1291,7 +1291,7 @@ impl<'a> Parser<'a> { } #[cfg(test)] -pub(crate) mod tests { +mod tests { use super::*; use crate::event::{Event, Tag, TagEnd}; @@ -1310,7 +1310,7 @@ pub(crate) mod tests { /// Collect the whole stream, owning nothing borrowed — `next` lends, so /// each event must be consumed before the next call. - pub(crate) fn debug_stream(markdown: &str) -> Vec { + fn debug_stream(markdown: &str) -> Vec { let mut parser = Parser::new(markdown, false, true); let mut out = Vec::new(); while let Some(event) = parser.next() { @@ -1427,6 +1427,39 @@ pub(crate) mod tests { ); } + #[test] + fn empty_wikilink_labels_do_not_repeat_following_content() { + for repetitions in [1, 4, 8, 12] { + let markdown = format!("{}tail", "[[a|]] ".repeat(repetitions)); + for directives in [false, true] { + let mut parser = Parser::new(&markdown, true, directives); + let mut events = 0; + let mut links = 0; + let mut ends = 0; + let mut text = String::new(); + while let Some(event) = parser.next() { + events += 1; + // Bound the failure before a regressed parser expands the rest. + assert!(events <= 4 * repetitions + 4, "{markdown:?}"); + match event { + Event::Start(Tag::Link { + kind: LinkKind::Wiki { has_pothole: true }, + dest_url, + }) => { + assert_eq!(dest_url.as_ref(), "a"); + links += 1; + } + Event::End(TagEnd::Link) => ends += 1, + Event::Text(value) => text.push_str(&value), + _ => {} + } + } + assert_eq!((links, ends), (repetitions, repetitions)); + assert_eq!(text, format!("{}tail", " ".repeat(repetitions))); + } + } + } + #[test] fn an_escape_opening_a_wikilink_display_is_decoded() { for (md, text) in [ @@ -2490,18 +2523,18 @@ pub(crate) mod tests { } #[test] - fn a_paragraph_cmark_opens_off_content_is_not_lone() { - // After a link reference definition, cmark opens an empty paragraph - // at the end of the source, or one at a tab. - for md in [ - "[a]: y\n\t", - "[a]: y\n \t", - "[a]: y\r\n\t", - "x\n\n[a]: y\n\t", - ] { - assert!(lone_images(md).iter().all(Option::is_none), "{md:?}"); + fn reference_definitions_and_blank_lines_do_not_create_paragraphs() { + for md in ["[a]: y\n\t", "[a]: y\n \t", "[a]: y\r\n\t"] { + assert_eq!(lone_images(md), [], "{md:?}"); + } + assert_eq!(lone_images("x\n\n[a]: y\n\t"), [None]); + } + + #[test] + fn an_image_after_a_reference_definition_and_blank_line_is_lone() { + for md in ["[a]: y\n\t\n![b](x.puml)", "[a]: y\r\n\t\r\n![b](x.puml)"] { + assert_eq!(lone_images(md), lone("x.puml"), "{md:?}"); } - assert_eq!(lone_images("[a]: y\n\t\n![b](x.puml)"), [None]); } #[test] diff --git a/crates/rw-parser/tests/reference_definitions.rs b/crates/rw-parser/tests/reference_definitions.rs new file mode 100644 index 000000000..4024551bf --- /dev/null +++ b/crates/rw-parser/tests/reference_definitions.rs @@ -0,0 +1,97 @@ +use std::panic::catch_unwind; + +use pretty_assertions::assert_eq; +use rw_parser::Parser; + +fn events(markdown: &str) -> Vec { + let mut parser = Parser::new(markdown, false, true); + let mut events = Vec::new(); + while let Some(event) = parser.next() { + events.push(format!("{event:?}")); + } + events +} + +#[test] +fn reference_definition_continuations_do_not_panic() { + for markdown in [ + "10. ![a](x.puml)\n > - [r]:\n x.puml\n ", + " \t:::note\n>1) [r]: x.puml 'T'\n ", + "- [r]: http://x\n \n- b\n", + ] { + assert!(catch_unwind(|| events(markdown)).is_ok(), "{markdown:?}"); + } +} + +#[test] +fn reference_definitions_preserve_following_list_items_and_links() { + assert_eq!( + events("- [r]: http://x\n \n- b\n\n[r]\n"), + [ + "Start(List(None))", + "Start(Item)", + "End(Item)", + "Start(Item)", + "Start(Paragraph { lone_image: false })", + "Text(Borrowed(\"b\"))", + "End(Paragraph)", + "End(Item)", + "End(List(false))", + "Start(Paragraph { lone_image: false })", + "Start(Link { kind: Other, dest_url: Borrowed(\"http://x\") })", + "Text(Borrowed(\"r\"))", + "End(Link)", + "End(Paragraph)", + ] + ); +} + +#[test] +fn reference_definition_whitespace_variations_do_not_panic() { + let outers = ["", "> ", " > ", "- ", "1. a\n", " "]; + let markers = ["- ", "* ", "1. ", "10) ", "- ", "- - "]; + let refdefs = [ + "[r]: http://x\n", + "[r]:\n x.puml\r\n", + "[r]: x.puml 'T'\r", + "[r]: \r\n \"t\"\n", + ]; + let quotes = ["", ">"]; + let blanks = [ + "", + " ", + " ", + " ", + " ", + " ", + "\t", + " \t", + "\t\t", + "\x0c", + " \x0b", + ]; + let tails = [ + "", + "\n", + "\n- b\n", + "\n\n- b\n", + "\n c\n", + "\n[r]\n", + "\r\n- b", + "\r- b\r", + ]; + for outer in outers { + for marker in markers { + for refdef in refdefs { + for quote in quotes { + for blank in blanks { + for tail in tails { + let markdown = format!("{outer}{marker}{refdef}{quote}{blank}{tail}"); + assert!(catch_unwind(|| events(&markdown)).is_ok(), "{markdown:?}"); + } + } + } + } + } + } +} diff --git a/crates/rw-renderer/src/renderer.rs b/crates/rw-renderer/src/renderer.rs index ee44c9690..00dd5654d 100644 --- a/crates/rw-renderer/src/renderer.rs +++ b/crates/rw-renderer/src/renderer.rs @@ -361,12 +361,11 @@ mod tests { assert_eq!(result.html, "

Hello, world!

"); } - /// Pages that trip pulldown-cmark's empty tight paragraph panic. #[test] - fn test_html_refdef_alone_in_a_tight_item_renders() { + fn test_html_reference_definition_continuations_render() { assert_eq!( render_html("- [r]: http://x\n \n- b\n\n[r]\n").html, - r#"
  • b

r

"# + r#"
  • b

r

"# ); assert_eq!( render_html("10. ![a](x.puml)\n > - [r]:\n x.puml\n ").html,