oni.git

oni

~ A static Git browser written in Rust.
git clone https://github.com/debuman/oni.git

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 }