//! README rendering: Markdown -> HTML, safely. //! //! pulldown-cmark normally copies raw HTML from the Markdown straight into the //! output. A README is untrusted input (anyone can commit one), so we turn raw //! HTML into plain text and drop `javascript:`-style links. use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag}; /// Where relative links and images in the README should point. pub struct LinkBase<'a> { /// Prefix for relative links, e.g. "/rust-kernel/tree/" pub links: &'a str, /// Prefix for relative images, e.g. "/rust-kernel/raw/" pub images: &'a str, } pub fn render(markdown: &str, base: Option<&LinkBase>) -> String { let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_FOOTNOTES; let events = Parser::new_ext(markdown, opts).map(|event| match event { // Raw HTML is shown as text instead of being executed by the browser. Event::Html(s) | Event::InlineHtml(s) => Event::Text(s), Event::Start(Tag::Link { link_type, dest_url, title, id }) => Event::Start(Tag::Link { link_type, dest_url: fix_url(dest_url, base.map(|b| b.links)), title, id, }), Event::Start(Tag::Image { link_type, dest_url, title, id }) => Event::Start(Tag::Image { link_type, dest_url: fix_url(dest_url, base.map(|b| b.images)), title, id, }), other => other, }); let mut out = String::new(); html::push_html(&mut out, events); out } /// Keep safe absolute URLs, rewrite relative ones, blank out everything else. fn fix_url<'a>(url: CowStr<'a>, base: Option<&str>) -> CowStr<'a> { let lower = url.trim().to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:") { return url; } if lower.starts_with('#') || lower.starts_with('/') { return url; } if lower.contains(':') { // javascript:, data:, vbscript:, file:, ... -> not allowed let before_slash = lower.split('/').next().unwrap_or(""); if before_slash.contains(':') { return CowStr::Borrowed("#"); } } match base { Some(prefix) => { let rel = url.trim_start_matches("./"); CowStr::from(format!("{prefix}{rel}")) } None => url, } } #[cfg(test)] mod tests { use super::*; #[test] fn renders_basic_markdown() { let html = render("# Title\n\nSome *text*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", None); assert!(html.contains("

Title

")); assert!(html.contains("text")); assert!(html.contains("")); } #[test] fn raw_html_is_escaped() { let html = render("hi \n\n
block
\n", None); assert!(!html.contains("