generator/src/markdown.rs (5692B)
1 use crate::Result;
2 use std::borrow::Cow;
3 use std::collections::{HashMap, HashSet};
4 use std::ffi::{c_char, c_int, c_uint, c_void};
5
6 const MAX_README_BYTES: usize = 4 * 1024 * 1024;
7 const MAX_RENDERED_BYTES: usize = 32 * 1024 * 1024;
8
9 struct RenderBuffer {
10 html: Vec<u8>,
11 failed: bool,
12 }
13
14 // MD4C's public C API; the parser is a system library, not vendored code.
15 #[link(name = "md4c-html")]
16 extern "C" {
17 fn md_html(
18 input: *const c_char,
19 length: c_uint,
20 callback: unsafe extern "C" fn(*const c_char, c_uint, *mut c_void),
21 context: *mut c_void,
22 parser_flags: c_uint,
23 renderer_flags: c_uint,
24 ) -> c_int;
25 }
26
27 unsafe extern "C" fn append(data: *const c_char, length: c_uint, context: *mut c_void) {
28 // md_html invokes this synchronously, with valid data for the duration of the call.
29 let output = unsafe { &mut *(context as *mut RenderBuffer) };
30 if length == 0 || output.failed {
31 return;
32 }
33 if data.is_null()
34 || length as usize > MAX_RENDERED_BYTES.saturating_sub(output.html.len())
35 || output.html.try_reserve(length as usize).is_err()
36 {
37 output.failed = true;
38 return;
39 }
40 let bytes = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), length as usize) };
41 output.html.extend_from_slice(bytes);
42 }
43
44 fn sanitize(html: &str) -> String {
45 let attributes: HashMap<_, HashSet<_>> = [
46 ("a", vec!["href", "title"]),
47 ("img", vec!["src", "alt", "title", "width", "height"]),
48 ("code", vec!["class"]),
49 ("td", vec!["align", "colspan", "rowspan"]),
50 ("th", vec!["align", "colspan", "rowspan"]),
51 ("ol", vec!["start"]),
52 ("input", vec!["checked"]),
53 ("details", vec!["open"]),
54 ]
55 .into_iter()
56 .map(|(tag, attrs)| (tag, attrs.into_iter().collect()))
57 .collect();
58 ammonia::Builder::default()
59 .tags(
60 [
61 "a",
62 "abbr",
63 "b",
64 "blockquote",
65 "br",
66 "caption",
67 "code",
68 "dd",
69 "del",
70 "details",
71 "div",
72 "dl",
73 "dt",
74 "em",
75 "h1",
76 "h2",
77 "h3",
78 "h4",
79 "h5",
80 "h6",
81 "hr",
82 "i",
83 "img",
84 "input",
85 "kbd",
86 "li",
87 "ol",
88 "p",
89 "pre",
90 "s",
91 "samp",
92 "small",
93 "span",
94 "strong",
95 "sub",
96 "summary",
97 "sup",
98 "table",
99 "tbody",
100 "td",
101 "th",
102 "thead",
103 "tr",
104 "ul",
105 "var",
106 ]
107 .into_iter()
108 .collect(),
109 )
110 .tag_attributes(attributes)
111 .generic_attributes(HashSet::new())
112 .url_schemes(["http", "https", "mailto"].into_iter().collect())
113 .set_tag_attribute_value("input", "type", "checkbox")
114 .set_tag_attribute_value("input", "disabled", "")
115 .attribute_filter(|tag, name, value| {
116 if matches!(name, "href" | "src") && !crate::html::safe_url(value) {
117 return None;
118 }
119 if tag == "code"
120 && name == "class"
121 && !(value.starts_with("language-")
122 && value.len() <= 80
123 && value
124 .bytes()
125 .all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte)))
126 {
127 return None;
128 }
129 if matches!(name, "width" | "height" | "colspan" | "rowspan")
130 && !value
131 .parse::<u32>()
132 .is_ok_and(|number| number > 0 && number <= 8192)
133 {
134 return None;
135 }
136 Some(Cow::Borrowed(value))
137 })
138 .clean(html)
139 .to_string()
140 }
141
142 pub fn render(input: &[u8]) -> Result<String> {
143 if input.len() > MAX_README_BYTES {
144 return Err("Markdown README exceeds the 4 MiB limit".into());
145 }
146 let length = c_uint::try_from(input.len())?;
147 let mut output = RenderBuffer {
148 html: Vec::new(),
149 failed: false,
150 };
151 // TABLES, TASKLISTS, PERMISSIVEURLAUTOLINKS, PERMISSIVEEMAILAUTOLINKS.
152 let flags = 0x100 | 0x800 | 0x4 | 0x8;
153 let result = unsafe {
154 md_html(
155 input.as_ptr().cast(),
156 length,
157 append,
158 (&mut output as *mut RenderBuffer).cast(),
159 flags,
160 0,
161 )
162 };
163 if result != 0 {
164 return Err(format!("MD4C returned {result}").into());
165 }
166 if output.failed {
167 return Err("Markdown output exceeds its memory limit".into());
168 }
169 Ok(sanitize(&String::from_utf8(output.html)?))
170 }
171
172 #[cfg(test)]
173 mod tests {
174 use super::*;
175 #[test]
176 fn renders_tables_tasks_details_and_fenced_code() {
177 let source = b"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n\n<details><summary>More</summary>text</details>\n\n~~~rust\nlet x = 1;\n~~~\n";
178 let html = render(source).unwrap();
179 for expected in [
180 "<h1>Title</h1>",
181 "<table>",
182 "type=\"checkbox\"",
183 "<details>",
184 "<code class=\"language-rust\">",
185 ] {
186 assert!(html.contains(expected), "{expected}: {html}");
187 }
188 }
189 }