OXYGENHUB

Coverage / crates/hub/src/render/markdown.rs 94.5%

52 of 55 lines run, 15 of 17 functions · run #501 on main · run missed

1
//! Markdown to sanitised HTML: pulldown-cmark, then ammonia with an
2
//! allow-list (hub spec §15.2). Relative links and images are rebased on
3
//! `base`, so a README's `docs/x.md` opens in the hub.
4
5
use pulldown_cmark::{html, Event, Options, Parser, Tag};
6
7
/// Rendered Markdown by (text, link bases): 32 MiB.
8254×
fn cache() -> &'static super::cache::Lru<String> {
9
    static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new();
10254×
    C.get_or_init(|| super::cache::Lru::new(32 << 20))
11254×
}
12
13
/// (hits, misses, bytes) of the rendered Markdown cache.
140×
pub fn cache_stats() -> (u64, u64, usize) {
150×
    cache().stats()
160×
}
17
18
/// `base_file` and `base_raw` are URL prefixes (ending in `/`) for links to
19
/// files and for images. Cached by the text and both bases.
20254×
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
21254×
    let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]);
22254×
    cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone()
23254×
}
24
2559×
fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String {
2659×
    let mut opts = Options::empty();
2759×
    opts.insert(Options::ENABLE_TABLES);
2859×
    opts.insert(Options::ENABLE_STRIKETHROUGH);
2959×
    opts.insert(Options::ENABLE_TASKLISTS);
3059×
    opts.insert(Options::ENABLE_FOOTNOTES);
3159×
    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
3291×
    let parser = Parser::new_ext(text, opts).map(|ev| match ev {
3316×
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => {
3416×
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
35
        }
364×
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
374×
            Event::Start(Tag::Image { link_type, dest_url: rebase(&dest_url, base_raw).into(), title, id })
38
        }
39274×
        other => other,
40294×
    });
4159×
    let mut out = String::with_capacity(text.len() * 2);
4259×
    html::push_html(&mut out, parser);
4359×
    let mut builder = ammonia::Builder::default();
4459×
    builder.add_generic_attributes(["id", "class"]);
4559×
    builder.add_tag_attributes("input", ["type", "checked", "disabled"]);
4659×
    builder.add_tags(["input"]);
4759×
    builder.link_rel(Some("noopener noreferrer"));
4859×
    builder.clean(&out).to_string()
4959×
}
50
51
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
5220×
fn rebase(url: &str, base: &str) -> String {
5320×
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
548×
        return url.to_string();
5512×
    }
5612×
    let url = url.strip_prefix("./").unwrap_or(url);
5712×
    format!("{base}{url}")
5820×
}
59
60
#[cfg(test)]
61
mod tests {
62
    use super::*;
63
64
    #[test]
651×
    fn renders_and_sanitises() {
661×
        let html = render(
671×
            "# Hi\n\nSee [docs](docs/x.md) and ![i](img.png) <script>alert(1)</script>\n\n- [ ] task\n",
681×
            "/a/b/file/main/",
691×
            "/a/b/file/main/",
70
        );
711×
        assert!(html.contains("<h1>Hi</h1>"));
721×
        assert!(html.contains("href=\"/a/b/file/main/docs/x.md\""));
731×
        assert!(html.contains("src=\"/a/b/file/main/img.png\""));
741×
        assert!(!html.contains("<script"));
751×
        assert!(html.contains("type=\"checkbox\""));
761×
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
771×
    }
78
}