OXYGENHUB

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

87 of 90 lines run, 26 of 30 functions · run #529 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.
8257×
fn cache() -> &'static super::cache::Lru<String> {
9
    static C: std::sync::OnceLock<super::cache::Lru<String>> = std::sync::OnceLock::new();
10257×
    C.get_or_init(|| super::cache::Lru::new(32 << 20))
11257×
}
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.
20257×
pub fn render(text: &str, base_file: &str, base_raw: &str) -> String {
21257×
    let key = super::cache::key(&[b"md1", text.as_bytes(), base_file.as_bytes(), base_raw.as_bytes()]);
22257×
    cache().get_or(key, || render_uncached(text, base_file, base_raw)).as_ref().clone()
23257×
}
24
2561×
fn render_uncached(text: &str, base_file: &str, base_raw: &str) -> String {
2661×
    let mut opts = Options::empty();
2761×
    opts.insert(Options::ENABLE_TABLES);
2861×
    opts.insert(Options::ENABLE_STRIKETHROUGH);
2961×
    opts.insert(Options::ENABLE_TASKLISTS);
3061×
    opts.insert(Options::ENABLE_FOOTNOTES);
3161×
    opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
3294×
    let parser = Parser::new_ext(text, opts).map(|ev| match ev {
3317×
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => {
3417×
            Event::Start(Tag::Link { link_type, dest_url: rebase(&dest_url, base_file).into(), title, id })
35
        }
365×
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => {
375×
            let src = raw_image(&dest_url, &origin()).unwrap_or_else(|| rebase(&dest_url, base_raw));
385×
            Event::Start(Tag::Image { link_type, dest_url: src.into(), title, id })
39
        }
40281×
        other => other,
41303×
    });
4261×
    let mut out = String::with_capacity(text.len() * 2);
4361×
    html::push_html(&mut out, parser);
4461×
    let mut builder = ammonia::Builder::default();
4561×
    builder.add_generic_attributes(["id", "class"]);
4661×
    builder.add_tag_attributes("input", ["type", "checked", "disabled"]);
4761×
    builder.add_tags(["input"]);
4861×
    builder.link_rel(Some("noopener noreferrer"));
4961×
    builder.clean(&out).to_string()
5061×
}
51
52
/// This hub's own origin (`https://oxygenhub.io`), so an image written as
53
/// a full address of one of its file pages is still recognised.
54
static ORIGIN: std::sync::RwLock<String> = std::sync::RwLock::new(String::new());
55
56
/// Set when the hub is built, from its base URL.
57203×
pub fn set_origin(base_url: &str) {
58203×
    *ORIGIN.write().unwrap() = base_url.trim_end_matches('/').to_string();
59203×
}
60
615×
fn origin() -> String {
625×
    ORIGIN.read().unwrap().clone()
635×
}
64
65
/// An image that points at one of this hub's file pages
66
/// (`[ORIGIN]/OWNER/NAME/file/REF/PATH`, the address a file's page shows)
67
/// is that file's raw bytes (`…/raw/REF/PATH`): the page is HTML, which an
68
/// `<img>` cannot show, as GitHub turns `blob/` images into raw ones.
6912×
fn raw_image(url: &str, origin: &str) -> Option<String> {
7012×
    let (host, path) = if url.starts_with('/') && !url.starts_with("//") {
712×
        ("", url)
7210×
    } else if !origin.is_empty() && url.strip_prefix(origin).is_some_and(|p| p.starts_with('/')) {
732×
        (origin, &url[origin.len()..])
74
    } else {
758×
        return None;
76
    };
774×
    let mut parts = path[1..].splitn(4, '/');
784×
    let (owner, name, kind, rest) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
794×
    (kind == "file" && !owner.is_empty() && !name.is_empty() && !rest.is_empty()).then(|| format!("{host}/{owner}/{name}/raw/{rest}"))
8012×
}
81
82
/// Leave absolute URLs, fragments and mail links alone; rebase the rest.
8321×
fn rebase(url: &str, base: &str) -> String {
8421×
    if url.starts_with('#') || url.contains("://") || url.starts_with("mailto:") || url.starts_with('/') || url.starts_with("data:") {
859×
        return url.to_string();
8612×
    }
8712×
    let url = url.strip_prefix("./").unwrap_or(url);
8812×
    format!("{base}{url}")
8921×
}
90
91
#[cfg(test)]
92
mod tests {
93
    use super::*;
94
95
    #[test]
961×
    fn renders_and_sanitises() {
971×
        let html = render(
981×
            "# Hi\n\nSee [docs](docs/x.md) and ![i](img.png) <script>alert(1)</script>\n\n- [ ] task\n",
991×
            "/a/b/file/main/",
1001×
            "/a/b/file/main/",
101
        );
1021×
        assert!(html.contains("<h1>Hi</h1>"));
1031×
        assert!(html.contains("href=\"/a/b/file/main/docs/x.md\""));
1041×
        assert!(html.contains("src=\"/a/b/file/main/img.png\""));
1051×
        assert!(!html.contains("<script"));
1061×
        assert!(html.contains("type=\"checkbox\""));
1071×
        assert!(render("[x](https://e.com) [y](#frag)", "/p/", "/p/").contains("href=\"https://e.com\""));
1081×
    }
109
110
    /// An image of one of this hub's file pages is its raw file: by full
111
    /// address or by path; another site's, a link, or a raw address stay.
112
    #[test]
1131×
    fn an_image_of_a_file_page_is_its_raw_file() {
1141×
        let o = "https://hub.example";
1151×
        assert_eq!(
1161×
            raw_image("https://hub.example/doomsdayone/tetriblox/file/main/docs/images/screens/menu.png", o).as_deref(),
117
            Some("https://hub.example/doomsdayone/tetriblox/raw/main/docs/images/screens/menu.png")
118
        );
1191×
        assert_eq!(raw_image("/a/b/file/v1.0/x.png", o).as_deref(), Some("/a/b/raw/v1.0/x.png"));
1201×
        assert_eq!(raw_image("https://elsewhere.example/a/b/file/main/x.png", o), None);
1211×
        assert_eq!(raw_image("https://hub.example.evil/a/b/file/main/x.png", o), None);
1221×
        assert_eq!(raw_image("/a/b/raw/main/x.png", o), None);
1231×
        assert_eq!(raw_image("//cdn.example/a/b/file/main/x.png", o), None);
1241×
        assert_eq!(raw_image("img.png", o), None);
125
        // Through render, with the origin set: only the image changes.
1261×
        set_origin("https://hub.example/");
1271×
        let html = render("[page](https://hub.example/a/b/file/main/x.png) ![x](https://hub.example/a/b/file/main/x.png)", "/p/", "/p/");
1281×
        assert!(html.contains("href=\"https://hub.example/a/b/file/main/x.png\""), "{html}");
1291×
        assert!(html.contains("src=\"https://hub.example/a/b/raw/main/x.png\""), "{html}");
1301×
    }
131
}