From 2d51600b6c509bc68a7dd6f2b5c4ed32bd3b05e1 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 15 Aug 2026 15:48:58 +0200 Subject: [PATCH] fix(markdown): stop escaped link destinations from swallowing later content (#1630) --- packages/markdown_parser/rust/src/links.rs | 34 ++++++++++++++--- .../markdown_parser/rust/tests/fixtures.rs | 37 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/markdown_parser/rust/src/links.rs b/packages/markdown_parser/rust/src/links.rs index 574d51f48..9fc1919c2 100644 --- a/packages/markdown_parser/rust/src/links.rs +++ b/packages/markdown_parser/rust/src/links.rs @@ -412,10 +412,7 @@ fn extract_escaped_url(text: &str, start: usize) -> Option> { if byte_at(text, pos) == b'>' { let url = &text[start..pos]; pos += 1; - while pos < text.len() && byte_at(text, pos) != b')' { - pos += advance_one(text, pos); - } - if pos >= text.len() { + if pos >= text.len() || byte_at(text, pos) != b')' { return None; } return Some(UrlInfo { @@ -1410,7 +1407,34 @@ pub fn max_inline_scan() -> usize { #[cfg(test)] mod tests { - use super::has_valid_code_fence_language; + use super::{extract_escaped_url, has_valid_code_fence_language}; + + #[test] + fn escaped_url_accepts_paren_immediately_after_destination() { + let info = extract_escaped_url("https://example.com>) trailing", 0).expect("valid"); + assert_eq!(info.url, "https://example.com"); + assert!(info.escaped); + assert_eq!(info.advance_by, "https://example.com>)".len()); + } + + #[test] + fn escaped_url_rejects_content_between_destination_and_paren() { + assert!(extract_escaped_url("https://example.com> and more)", 0).is_none()); + assert!(extract_escaped_url("https://example.com> )", 0).is_none()); + assert!(extract_escaped_url("https://example.com>text)", 0).is_none()); + } + + #[test] + fn escaped_url_rejects_missing_paren() { + assert!(extract_escaped_url("https://example.com>", 0).is_none()); + assert!(extract_escaped_url("https://example.com", 0).is_none()); + } + + #[test] + fn escaped_url_keeps_paren_inside_destination() { + let info = extract_escaped_url("https://example.com/a.yaml)>)", 0).expect("valid"); + assert_eq!(info.url, "https://example.com/a.yaml)"); + } #[test] fn rejects_leading_whitespace() { diff --git a/packages/markdown_parser/rust/tests/fixtures.rs b/packages/markdown_parser/rust/tests/fixtures.rs index 2646a0003..f31b0d3c7 100644 --- a/packages/markdown_parser/rust/tests/fixtures.rs +++ b/packages/markdown_parser/rust/tests/fixtures.rs @@ -259,6 +259,43 @@ fn native_parser_rejects_apostrophe_in_masked_link_authority() { ); } +#[test] +fn native_parser_keeps_content_after_unterminated_escaped_destination() { + let flags = ParserFlags::ALLOW_MASKED_LINKS | ParserFlags::ALLOW_AUTOLINKS; + assert_eq!( + parse( + "a [x]( b [e]()", + flags, + "" + ), + json!({"nodes":[ + {"type":"Text","content":"a [x]("}, + {"type":"Link","url":"https://example.com/y)","escaped":true,"rawUrl":"https://example.com/y)","source":""}, + {"type":"Text","content":" b "}, + {"type":"Link","text":{"type":"Text","content":"e"},"url":"https://example.com/f","escaped":true,"rawUrl":"https://example.com/f","source":"[e]()"} + ]}) + ); + assert_eq!( + parse("[bad]( )", flags, ""), + json!({"nodes":[ + {"type":"Text","content":"[bad]("}, + {"type":"Link","url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":""}, + {"type":"Text","content":" )"} + ]}) + ); +} + +#[test] +fn native_parser_still_accepts_well_formed_escaped_destinations() { + let flags = ParserFlags::ALLOW_MASKED_LINKS | ParserFlags::ALLOW_AUTOLINKS; + assert_eq!( + parse("[ok]()", flags, ""), + json!({"nodes":[ + {"type":"Link","text":{"type":"Text","content":"ok"},"url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":"[ok]()"} + ]}) + ); +} + #[test] fn native_parser_rejects_masked_links_without_visible_text() { for input in [