Krolyxon
Commit e758199

ferreniumsrcrendermarkdown.rs

314 lines · 10.1 KB · Rust

1//! Markdown (GFM) to sanitized HTML.
2//!
3//! comrak renders with raw HTML allowed; ammonia then sanitizes everything,
4//! including that raw HTML. Relative links and images (from Markdown or
5//! raw HTML alike) are rewritten to point into the repository: links to the
6//! blob view, images to the raw endpoint.
7
8use std::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11
12use comrak::adapters::SyntaxHighlighterAdapter;
13use comrak::options::Plugins;
14use comrak::{Options, markdown_to_html_with_plugins};
15
16use super::{encode_bytes, escape_html, highlight, percent_decode};
17
18/// Where relative URLs in a document resolve to.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Links {
21 /// URL of the repository, without a trailing slash (`/git/forks/foo`).
22 pub repo_url: String,
23 /// Revision as it appears in URLs (already percent-encoded).
24 pub rev: String,
25 /// Directory of the document inside the tree (no slashes at the ends;
26 /// empty for the root).
27 pub dir: Vec<u8>,
28}
29
30impl Links {
31 /// Cache-key fragment identifying everything the output depends on.
32 pub fn key(&self) -> String {
33 format!(
34 "{}\0{}\0{}",
35 self.repo_url,
36 self.rev,
37 String::from_utf8_lossy(&self.dir)
38 )
39 }
40}
41
42/// Is this a Markdown file (by name)?
43pub fn is_markdown(path: &str) -> bool {
44 let lower = path.to_ascii_lowercase();
45 [".md", ".markdown", ".mdown", ".mkd", ".mkdn"]
46 .iter()
47 .any(|ext| lower.ends_with(ext))
48}
49
50pub fn render(markdown: &str, links: &Links) -> String {
51 let mut options = Options::default();
52 let ext = &mut options.extension;
53 ext.strikethrough = true;
54 ext.table = true;
55 ext.autolink = true;
56 ext.tasklist = true;
57 ext.footnotes = true;
58 ext.alerts = true;
59 ext.header_id_prefix = Some(String::new());
60 ext.front_matter_delimiter = Some("---".into());
61 options.render.r#unsafe = true;
62 options.render.github_pre_lang = false;
63
64 let mut plugins = Plugins::default();
65 let highlighter = FenceHighlighter;
66 plugins.render.codefence_syntax_highlighter = Some(&highlighter);
67 let html = markdown_to_html_with_plugins(markdown, &options, &plugins);
68 sanitize(&html, links.clone())
69}
70
71/// Plain text (non-Markdown READMEs) as an escaped `<pre>`.
72pub fn render_plain(text: &str) -> String {
73 let mut out = String::from("<pre class=\"plain\">");
74 escape_html(&mut out, text);
75 out.push_str("</pre>");
76 out
77}
78
79fn sanitize(html: &str, links: Links) -> String {
80 let mut builder = ammonia::Builder::default();
81 builder
82 .add_tags(["input"])
83 .add_tag_attributes("input", ["checked"])
84 .set_tag_attribute_value("input", "type", "checkbox")
85 .set_tag_attribute_value("input", "disabled", "")
86 .add_generic_attributes(["id", "align"])
87 .add_tag_attributes("a", ["aria-hidden"])
88 .add_tag_attributes("img", ["title"])
89 .add_tag_attributes("ol", ["start"])
90 .add_tag_attributes("section", ["data-footnotes"])
91 .add_tags(["section"])
92 .link_rel(Some("noopener noreferrer nofollow"))
93 .allowed_classes(allowed_classes())
94 .url_relative(ammonia::UrlRelative::PassThrough)
95 .attribute_filter(move |element, attribute, value| {
96 match (element, attribute) {
97 ("a", "href") => rewrite(&links, value, "blob"),
98 ("img", "src") => rewrite(&links, value, "raw"),
99 _ => None,
100 }
101 .map_or(Some(Cow::Borrowed(value)), |v| Some(Cow::Owned(v)))
102 });
103 builder.clean(html).to_string()
104}
105
106fn allowed_classes() -> HashMap<&'static str, HashSet<&'static str>> {
107 let set = |items: &[&'static str]| items.iter().copied().collect::<HashSet<_>>();
108 let alerts = [
109 "markdown-alert",
110 "markdown-alert-note",
111 "markdown-alert-tip",
112 "markdown-alert-important",
113 "markdown-alert-warning",
114 "markdown-alert-caution",
115 ];
116 HashMap::from([
117 ("span", set(&["hl-k", "hl-d", "hl-s", "hl-c", "hl-x"])),
118 ("div", set(&alerts)),
119 ("p", set(&["markdown-alert-title"])),
120 ("a", set(&["anchor"])),
121 ("section", set(&["footnotes"])),
122 ("ul", set(&["contains-task-list"])),
123 ("li", set(&["task-list-item"])),
124 ])
125}
126
127/// `scheme:` per the URL spec, before any `/`, `?` or `#`.
128fn has_scheme(url: &str) -> bool {
129 let Some(colon) = url.find(':') else {
130 return false;
131 };
132 let scheme = &url[..colon];
133 scheme
134 .chars()
135 .next()
136 .is_some_and(|c| c.is_ascii_alphabetic())
137 && scheme
138 .chars()
139 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
140}
141
142/// Rewrite a repository-relative URL; `None` leaves the value unchanged.
143fn rewrite(links: &Links, url: &str, view: &str) -> Option<String> {
144 if url.is_empty() || url.starts_with('#') || url.starts_with("//") || has_scheme(url) {
145 return None;
146 }
147 let (rest, fragment) = match url.split_once('#') {
148 Some((rest, f)) => (rest, Some(f)),
149 None => (url, None),
150 };
151 let path = rest.split_once('?').map_or(rest, |(p, _)| p);
152 let path = percent_decode(path);
153
154 let mut parts: Vec<&[u8]> = Vec::new();
155 if !path.starts_with(b"/") {
156 parts.extend(links.dir.split(|b| *b == b'/').filter(|p| !p.is_empty()));
157 }
158 for part in path.split(|b| *b == b'/') {
159 match part {
160 b"" | b"." => {}
161 b".." => {
162 parts.pop();
163 }
164 part => parts.push(part),
165 }
166 }
167 let joined = parts.join(&b'/');
168 let mut out = if joined.is_empty() {
169 format!("{}/tree/{}", links.repo_url, links.rev)
170 } else {
171 format!(
172 "{}/{view}/{}/{}",
173 links.repo_url,
174 links.rev,
175 encode_bytes(&joined)
176 )
177 };
178 if let Some(fragment) = fragment {
179 out.push('#');
180 out.push_str(fragment);
181 }
182 Some(out)
183}
184
185/// Highlights fenced code blocks with the same classes as the blob view.
186struct FenceHighlighter;
187
188impl SyntaxHighlighterAdapter for FenceHighlighter {
189 fn write_highlighted(
190 &self,
191 output: &mut dyn fmt::Write,
192 lang: Option<&str>,
193 code: &str,
194 ) -> fmt::Result {
195 match lang.and_then(highlight::for_token) {
196 Some(syntax) => output.write_str(&highlight::lines(code, syntax).join("\n")),
197 None => {
198 let mut escaped = String::with_capacity(code.len());
199 escape_html(&mut escaped, code.trim_end_matches('\n'));
200 output.write_str(&escaped)
201 }
202 }
203 }
204
205 fn write_pre_tag(
206 &self,
207 output: &mut dyn fmt::Write,
208 _attributes: HashMap<&'static str, Cow<'_, str>>,
209 ) -> fmt::Result {
210 output.write_str("<pre>")
211 }
212
213 fn write_code_tag(
214 &self,
215 output: &mut dyn fmt::Write,
216 _attributes: HashMap<&'static str, Cow<'_, str>>,
217 ) -> fmt::Result {
218 output.write_str("<code>")
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn links(dir: &str) -> Links {
227 Links {
228 repo_url: "/r".into(),
229 rev: "main".into(),
230 dir: dir.as_bytes().to_vec(),
231 }
232 }
233
234 #[test]
235 fn strips_dangerous_html() {
236 let html = render(
237 "<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n\
238 [x](javascript:alert(1)) <a href=\"data:text/html,hi\">d</a>\n\n\
239 <iframe src=\"https://evil\"></iframe><style>body{}</style>\n\n\
240 <div style=\"color:red\" onclick=\"x()\">s</div>",
241 &links(""),
242 );
243 for bad in [
244 "<script",
245 "onerror",
246 "javascript:",
247 "data:text",
248 "<iframe",
249 "<style",
250 "onclick",
251 "style=",
252 ] {
253 assert!(!html.contains(bad), "{bad} survived: {html}");
254 }
255 }
256
257 #[test]
258 fn rewrites_relative_urls() {
259 let html = render(
260 "[a](docs/guide.md#install) [b](../up.md) [c](/root.md) [d](https://x.org/y) \
261 [e](#frag) [f](./) ![i](img/logo.png) <img src=\"pic%20s.svg\"> \
262 <a href=\"sub/\">raw</a>",
263 &links("docs"),
264 );
265 assert!(
266 html.contains(r#"href="/r/blob/main/docs/docs/guide.md#install""#),
267 "{html}"
268 );
269 assert!(html.contains(r#"href="/r/blob/main/up.md""#), "{html}");
270 assert!(html.contains(r#"href="/r/blob/main/root.md""#), "{html}");
271 assert!(html.contains(r#"href="https://x.org/y""#), "{html}");
272 assert!(html.contains(r##"href="#frag""##), "{html}");
273 assert!(html.contains(r#"href="/r/blob/main/docs""#), "{html}");
274 assert!(
275 html.contains(r#"src="/r/raw/main/docs/img/logo.png""#),
276 "{html}"
277 );
278 assert!(
279 html.contains(r#"src="/r/raw/main/docs/pic%20s.svg""#),
280 "{html}"
281 );
282 assert!(html.contains(r#"href="/r/blob/main/docs/sub""#), "{html}");
283 // `..` can't escape the repository root.
284 assert_eq!(
285 rewrite(&links(""), "../../x", "blob").unwrap(),
286 "/r/blob/main/x"
287 );
288 assert_eq!(rewrite(&links(""), "..", "blob").unwrap(), "/r/tree/main");
289 }
290
291 #[test]
292 fn gfm_features_survive_sanitizing() {
293 let html = render(
294 "# Title\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n\
295 > [!NOTE]\n> careful\n\nfoot[^1]\n\n[^1]: note\n\n```rust\nfn x() {}\n```\n",
296 &links(""),
297 );
298 assert!(html.contains("<table>"), "{html}");
299 assert!(html.contains(r#"type="checkbox""#), "{html}");
300 assert!(html.contains("markdown-alert-note"), "{html}");
301 assert!(html.contains(r#"id="title""#), "{html}");
302 assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}");
303 assert!(html.contains("footnote"), "{html}");
304 }
305
306 #[test]
307 fn scheme_detection() {
308 assert!(has_scheme("https://x"));
309 assert!(has_scheme("mailto:a@b"));
310 assert!(!has_scheme("a/b:c"));
311 assert!(!has_scheme("x.md"));
312 assert!(!has_scheme(":x"));
313 }
314}