1use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
28use serde::{Deserialize, Serialize};
29
30const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Node {
36 Paragraph {
37 content: Vec<Inline>,
38 },
39 Heading {
40 level: u8,
41 content: Vec<Inline>,
42 },
43 List {
44 ordered: bool,
45 items: Vec<Vec<Node>>,
46 },
47 CodeBlock {
48 language: Option<String>,
49 code: String,
50 },
51 BlockQuote {
52 content: Vec<Node>,
53 },
54 Rule,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Inline {
60 Text {
61 text: String,
62 },
63 Code {
64 code: String,
65 },
66 Emphasis {
67 content: Vec<Inline>,
68 },
69 Strong {
70 content: Vec<Inline>,
71 },
72 Strikethrough {
73 content: Vec<Inline>,
74 },
75 Link {
77 href: String,
78 content: Vec<Inline>,
79 },
80 Image {
81 source: ImageSource,
82 alt: String,
83 },
84 SoftBreak,
85 HardBreak,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum ImageSource {
91 Local { path: String },
94 Blocked { url: String },
98}
99
100#[must_use]
102pub fn render(markdown: &str) -> Vec<Node> {
103 let mut options = Options::empty();
104 options.insert(Options::ENABLE_STRIKETHROUGH);
105 options.insert(Options::ENABLE_TABLES);
106 options.insert(Options::ENABLE_FOOTNOTES);
107 let mut blocks = Vec::new();
111 let mut parser = Parser::new_ext(markdown, options).peekable();
112 while let Some(event) = parser.next() {
113 if let Some(node) = block(&mut parser, event) {
114 blocks.push(node);
115 }
116 }
117 blocks
118}
119
120type EventStream<'a> = std::iter::Peekable<Parser<'a>>;
121
122fn block(p: &mut EventStream<'_>, event: Event<'_>) -> Option<Node> {
123 match event {
124 Event::Start(Tag::Paragraph) => Some(Node::Paragraph {
125 content: inlines(p, TagEnd::Paragraph),
126 }),
127 Event::Start(Tag::Heading { level, .. }) => Some(Node::Heading {
128 level: heading_level(level),
129 content: inlines(p, TagEnd::Heading(level)),
130 }),
131 Event::Start(Tag::CodeBlock(kind)) => {
132 let language = match kind {
133 pulldown_cmark::CodeBlockKind::Fenced(l) if !l.is_empty() => Some(l.to_string()),
134 _ => None,
135 };
136 let mut code = String::new();
137 for e in p.by_ref() {
138 match e {
139 Event::Text(t) | Event::Html(t) | Event::InlineHtml(t) => code.push_str(&t),
142 Event::End(TagEnd::CodeBlock) => break,
143 _ => {}
144 }
145 }
146 Some(Node::CodeBlock { language, code })
147 }
148 Event::Start(Tag::BlockQuote(_)) => {
149 let mut content = Vec::new();
150 while let Some(e) = p.next() {
151 if matches!(e, Event::End(TagEnd::BlockQuote(_))) {
152 break;
153 }
154 if let Some(n) = block(p, e) {
155 content.push(n);
156 }
157 }
158 Some(Node::BlockQuote { content })
159 }
160 Event::Start(Tag::List(start)) => {
161 let ordered = start.is_some();
162 let mut items = Vec::new();
163 while let Some(e) = p.next() {
164 match e {
165 Event::Start(Tag::Item) => {
166 let mut item = Vec::new();
167 while let Some(ie) = p.next() {
168 if matches!(ie, Event::End(TagEnd::Item)) {
169 break;
170 }
171 if let Some(n) = block(p, ie.clone()) {
175 item.push(n);
176 } else if let Some(inline) = inline_of(&ie) {
177 match item.last_mut() {
178 Some(Node::Paragraph { content }) => content.push(inline),
179 _ => item.push(Node::Paragraph {
180 content: vec![inline],
181 }),
182 }
183 }
184 }
185 items.push(item);
186 }
187 Event::End(TagEnd::List(_)) => break,
188 _ => {}
189 }
190 }
191 Some(Node::List { ordered, items })
192 }
193 Event::Rule => Some(Node::Rule),
194 other => inline_of(&other).map(|i| Node::Paragraph { content: vec![i] }),
196 }
197}
198
199fn inlines(p: &mut EventStream<'_>, until: TagEnd) -> Vec<Inline> {
200 let mut out = Vec::new();
201 while let Some(e) = p.next() {
202 if e == Event::End(until) {
203 break;
204 }
205 match e {
206 Event::Start(Tag::Emphasis) => out.push(Inline::Emphasis {
207 content: inlines(p, TagEnd::Emphasis),
208 }),
209 Event::Start(Tag::Strong) => out.push(Inline::Strong {
210 content: inlines(p, TagEnd::Strong),
211 }),
212 Event::Start(Tag::Strikethrough) => out.push(Inline::Strikethrough {
213 content: inlines(p, TagEnd::Strikethrough),
214 }),
215 Event::Start(Tag::Link { dest_url, .. }) => {
216 let content = inlines(p, TagEnd::Link);
217 match sanitize_href(&dest_url) {
218 None => out.extend(content),
220 Some(href) => out.push(Inline::Link { href, content }),
221 }
222 }
223 Event::Start(Tag::Image { dest_url, .. }) => {
224 let alt = plain_text(&inlines(p, TagEnd::Image));
225 out.push(Inline::Image {
226 source: image_source(&dest_url),
227 alt,
228 });
229 }
230 other => {
231 if let Some(i) = inline_of(&other) {
232 out.push(i);
233 }
234 }
235 }
236 }
237 out
238}
239
240fn inline_of(e: &Event<'_>) -> Option<Inline> {
241 match e {
242 Event::Text(t) => Some(Inline::Text {
243 text: t.to_string(),
244 }),
245 Event::Code(c) => Some(Inline::Code {
246 code: c.to_string(),
247 }),
248 Event::Html(h) | Event::InlineHtml(h) => Some(Inline::Text {
251 text: h.to_string(),
252 }),
253 Event::SoftBreak => Some(Inline::SoftBreak),
254 Event::HardBreak => Some(Inline::HardBreak),
255 _ => None,
256 }
257}
258
259fn sanitize_href(raw: &str) -> Option<String> {
263 let trimmed = raw.trim();
264 if trimmed.is_empty() {
265 return None;
266 }
267 if trimmed.chars().any(char::is_control) {
270 return None;
271 }
272 match scheme_of(trimmed) {
273 None => Some(trimmed.to_owned()),
274 Some(scheme) => ALLOWED_SCHEMES
275 .contains(&scheme.as_str())
276 .then(|| trimmed.to_owned()),
277 }
278}
279
280fn scheme_of(s: &str) -> Option<String> {
283 let end = s.find(':')?;
284 let candidate = &s[..end];
285 if candidate.is_empty() || s[..end].contains(['/', '?', '#']) {
286 return None;
287 }
288 let mut chars = candidate.chars();
290 let first = chars.next()?;
291 if !first.is_ascii_alphabetic() {
292 return None;
293 }
294 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
295 return None;
296 }
297 Some(candidate.to_ascii_lowercase())
298}
299
300fn image_source(raw: &str) -> ImageSource {
301 let trimmed = raw.trim();
302 match scheme_of(trimmed) {
303 None => ImageSource::Local {
305 path: trimmed.to_owned(),
306 },
307 Some(s) if s == "file" => ImageSource::Local {
308 path: trimmed.to_owned(),
309 },
310 _ => ImageSource::Blocked {
311 url: trimmed.to_owned(),
312 },
313 }
314}
315
316fn plain_text(inlines: &[Inline]) -> String {
317 let mut s = String::new();
318 for i in inlines {
319 match i {
320 Inline::Text { text } => s.push_str(text),
321 Inline::Code { code } => s.push_str(code),
322 Inline::Emphasis { content }
323 | Inline::Strong { content }
324 | Inline::Strikethrough { content }
325 | Inline::Link { content, .. } => s.push_str(&plain_text(content)),
326 Inline::SoftBreak | Inline::HardBreak => s.push(' '),
327 Inline::Image { alt, .. } => s.push_str(alt),
328 }
329 }
330 s
331}
332
333fn heading_level(l: HeadingLevel) -> u8 {
334 match l {
335 HeadingLevel::H1 => 1,
336 HeadingLevel::H2 => 2,
337 HeadingLevel::H3 => 3,
338 HeadingLevel::H4 => 4,
339 HeadingLevel::H5 => 5,
340 HeadingLevel::H6 => 6,
341 }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
346mod tests {
347 use super::*;
348
349 fn visible(nodes: &[Node]) -> String {
352 fn walk(nodes: &[Node], out: &mut String) {
353 for n in nodes {
354 match n {
355 Node::Paragraph { content } | Node::Heading { content, .. } => {
356 out.push_str(&plain_text(content));
357 out.push('\n');
358 }
359 Node::CodeBlock { code, .. } => out.push_str(code),
360 Node::BlockQuote { content } => walk(content, out),
361 Node::List { items, .. } => {
362 for i in items {
363 walk(i, out);
364 }
365 }
366 Node::Rule => out.push('\n'),
367 }
368 }
369 }
370 let mut s = String::new();
371 walk(nodes, &mut s);
372 s
373 }
374
375 fn links(nodes: &[Node]) -> Vec<String> {
376 fn walk_inline(is: &[Inline], out: &mut Vec<String>) {
377 for i in is {
378 match i {
379 Inline::Link { href, content } => {
380 out.push(href.clone());
381 walk_inline(content, out);
382 }
383 Inline::Emphasis { content }
384 | Inline::Strong { content }
385 | Inline::Strikethrough { content } => walk_inline(content, out),
386 _ => {}
387 }
388 }
389 }
390 fn walk(nodes: &[Node], out: &mut Vec<String>) {
391 for n in nodes {
392 match n {
393 Node::Paragraph { content } | Node::Heading { content, .. } => {
394 walk_inline(content, out);
395 }
396 Node::BlockQuote { content } => walk(content, out),
397 Node::List { items, .. } => {
398 for i in items {
399 walk(i, out);
400 }
401 }
402 _ => {}
403 }
404 }
405 }
406 let mut v = Vec::new();
407 walk(nodes, &mut v);
408 v
409 }
410
411 fn images(nodes: &[Node]) -> Vec<ImageSource> {
412 let mut out = Vec::new();
413 fn walk_inline(is: &[Inline], out: &mut Vec<ImageSource>) {
414 for i in is {
415 match i {
416 Inline::Image { source, .. } => out.push(source.clone()),
417 Inline::Emphasis { content }
418 | Inline::Strong { content }
419 | Inline::Strikethrough { content }
420 | Inline::Link { content, .. } => walk_inline(content, out),
421 _ => {}
422 }
423 }
424 }
425 for n in nodes {
426 if let Node::Paragraph { content } | Node::Heading { content, .. } = n {
427 walk_inline(content, &mut out);
428 }
429 }
430 out
431 }
432
433 #[test]
434 fn ordinary_markdown_still_formats() {
435 let nodes = render("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
436 assert!(matches!(nodes[0], Node::Heading { level: 1, .. }));
437 let seen = visible(&nodes);
438 assert!(seen.contains("Title"), "{seen}");
439 assert!(seen.contains("bold"), "{seen}");
440 assert!(seen.contains("one"), "{seen}");
441 assert!(
442 nodes.iter().any(|n| matches!(n, Node::List { .. })),
443 "expected a list: {nodes:#?}"
444 );
445 }
446
447 #[test]
448 fn fenced_code_survives_verbatim() {
449 let nodes = render("```rust\nlet x = 1;\n```\n");
450 let Some(Node::CodeBlock { language, code }) = nodes.first() else {
451 panic!("expected a code block, got {nodes:#?}");
452 };
453 assert_eq!(language.as_deref(), Some("rust"));
454 assert_eq!(code, "let x = 1;\n");
455 }
456
457 #[test]
459 fn raw_html_becomes_literal_text() {
460 for body in [
461 "<script>alert(1)</script>",
462 "<img src=x onerror=alert(1)>",
463 "<iframe src=\"https://evil.example\"></iframe>",
464 "hello <b>there</b>",
465 "<svg/onload=alert(1)>",
466 ] {
467 let nodes = render(body);
468 let seen = visible(&nodes);
469 assert!(
470 seen.contains('<'),
471 "markup should survive as text for {body:?}, got {seen:?}"
472 );
473 assert!(
475 links(&nodes).is_empty(),
476 "raw html produced a link for {body:?}"
477 );
478 }
479 }
480
481 #[test]
482 fn dangerous_link_schemes_lose_the_link_and_keep_the_text() {
483 for body in [
484 "[click](javascript:alert(1))",
485 "[click](JaVaScRiPt:alert(1))",
486 "[click]( javascript:alert(1) )",
487 "[click](data:text/html;base64,PHNjcmlwdD4=)",
488 "[click](vbscript:msgbox)",
489 "[click](file:///etc/passwd)",
490 ] {
491 let nodes = render(body);
492 assert!(
493 links(&nodes).is_empty(),
494 "{body:?} should not produce a link, got {:?}",
495 links(&nodes)
496 );
497 assert!(
498 visible(&nodes).contains("click"),
499 "{body:?} should keep its text"
500 );
501 }
502 }
503
504 #[test]
505 fn control_characters_cannot_smuggle_a_scheme() {
506 for body in [
509 "[x](java\u{0}script:alert(1))",
510 "[x](java\nscript:alert(1))",
511 "[x](\u{0}javascript:alert(1))",
512 ] {
513 let nodes = render(body);
514 assert!(
515 links(&nodes).is_empty(),
516 "{body:?} smuggled a link through: {:?}",
517 links(&nodes)
518 );
519 }
520 }
521
522 #[test]
523 fn ordinary_links_are_kept() {
524 let nodes = render("[docs](https://example.com/a?b=1#c) and [mail](mailto:a@b.c)");
525 let hrefs = links(&nodes);
526 assert!(
527 hrefs.contains(&"https://example.com/a?b=1#c".to_owned()),
528 "{hrefs:?}"
529 );
530 assert!(hrefs.contains(&"mailto:a@b.c".to_owned()), "{hrefs:?}");
531 }
532
533 #[test]
534 fn relative_and_fragment_links_are_kept() {
535 let nodes = render("[a](#section) [b](./notes.md) [c](/abs/path)");
537 assert_eq!(links(&nodes).len(), 3, "{:?}", links(&nodes));
538 }
539
540 #[test]
543 fn remote_images_are_blocked_local_ones_are_not() {
544 let nodes = render("");
545 assert_eq!(
546 images(&nodes),
547 vec![ImageSource::Blocked {
548 url: "https://evil.example/pixel.png".to_owned()
549 }]
550 );
551
552 let nodes = render("");
553 assert_eq!(
554 images(&nodes),
555 vec![ImageSource::Local {
556 path: "./diagram.png".to_owned()
557 }]
558 );
559 }
560
561 #[test]
562 fn image_alt_text_is_plain() {
563 let nodes = render("");
564 let Some(Node::Paragraph { content }) = nodes.first() else {
565 panic!("expected paragraph, got {nodes:#?}");
566 };
567 let Some(Inline::Image { alt, .. }) = content.first() else {
568 panic!("expected image, got {content:#?}");
569 };
570 assert!(alt.contains("script"), "alt should keep text: {alt:?}");
571 }
572
573 #[test]
574 fn a_plain_message_is_still_a_paragraph() {
575 let nodes = render("just a normal message");
577 assert_eq!(visible(&nodes).trim(), "just a normal message");
578 }
579
580 #[test]
581 fn empty_input_is_empty_output() {
582 assert!(render("").is_empty());
583 assert!(render(" \n \n").is_empty());
584 }
585
586 #[test]
587 fn nested_structures_do_not_lose_content() {
588 let nodes = render("> quoted **bold**\n>\n> - item one\n> - item two\n");
589 let seen = visible(&nodes);
590 assert!(seen.contains("quoted"), "{seen}");
591 assert!(seen.contains("bold"), "{seen}");
592 assert!(seen.contains("item one"), "{seen}");
593 assert!(seen.contains("item two"), "{seen}");
594 }
595
596 #[test]
597 fn scheme_detection_does_not_trip_on_paths() {
598 assert_eq!(scheme_of("https://a.example/x:y"), Some("https".into()));
600 assert_eq!(scheme_of("/path/to:thing"), None);
601 assert_eq!(scheme_of("#frag:ment"), None);
602 assert_eq!(scheme_of("no-colon-here"), None);
603 assert_eq!(scheme_of("1nvalid:x"), None);
604 }
605}