1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag};
pub struct LinkBase<'a> {
pub links: &'a str,
pub images: &'a str,
}
pub fn render(markdown: &str, base: Option<&LinkBase>) -> String {
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_FOOTNOTES;
let events = Parser::new_ext(markdown, opts).map(|event| match event {
Event::Html(s) | Event::InlineHtml(s) => Event::Text(s),
Event::Start(Tag::Link { link_type, dest_url, title, id }) => Event::Start(Tag::Link {
link_type,
dest_url: fix_url(dest_url, base.map(|b| b.links)),
title,
id,
}),
Event::Start(Tag::Image { link_type, dest_url, title, id }) => Event::Start(Tag::Image {
link_type,
dest_url: fix_url(dest_url, base.map(|b| b.images)),
title,
id,
}),
other => other,
});
let mut out = String::new();
html::push_html(&mut out, events);
out
}
fn fix_url<'a>(url: CowStr<'a>, base: Option<&str>) -> CowStr<'a> {
let lower = url.trim().to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:") {
return url;
}
if lower.starts_with('#') || lower.starts_with('/') {
return url;
}
if lower.contains(':') {
let before_slash = lower.split('/').next().unwrap_or("");
if before_slash.contains(':') {
return CowStr::Borrowed("#");
}
}
match base {
Some(prefix) => {
let rel = url.trim_start_matches("./");
CowStr::from(format!("{prefix}{rel}"))
}
None => url,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_basic_markdown() {
let html = render("# Title\n\nSome *text*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", None);
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<em>text</em>"));
assert!(html.contains("<table>"));
}
#[test]
fn raw_html_is_escaped() {
let html = render("hi <script>alert(1)</script>\n\n<div onclick=x>block</div>\n", None);
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
assert!(!html.contains("<div"));
}
#[test]
fn dangerous_links_are_removed() {
let html = render("[x](javascript:alert(1)) [y](JavaScript:alert(1))", None);
assert!(!html.to_lowercase().contains("javascript:"));
let html = render("", None);
assert!(!html.contains("data:"));
}
#[test]
fn relative_links_are_rewritten() {
let base = LinkBase { links: "/r/tree/", images: "/r/raw/" };
let html = render("[doc](docs/a.md)  [web](https://e.com)", Some(&base));
assert!(html.contains("href=\"/r/tree/docs/a.md\""));
assert!(html.contains("src=\"/r/raw/boot.png\""));
assert!(html.contains("href=\"https://e.com\""));
}
}
|