15c35c06cdea1711d13d2bf9060bc4610e4b22c2 / frogue / src / markdown.rs
Raw
//! Rendering a repository's readme, safely.
//!
//! A readme is markdown, and markdown may contain raw HTML. The content is a
//! repository's, not the server's, so the HTML is sanitized before it is
//! embedded in a page: anything that could run - scripts, event handlers, a
//! `javascript:` link - is removed, and what is left is a subset a browser can
//! only lay out.
//!
//! [`render`] is the only way a page embeds content it did not write itself,
//! which is why it lives on its own rather than inside a view.
use std::path::Path;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
/// Summaries of referenced tickets and patches, looked up by number.
#[derive(Default)]
pub struct Summaries {
pub tickets: std::collections::HashMap<i64, String>,
pub patches: std::collections::HashMap<i64, String>,
}
/// Collects all shorthand references (`#N` tickets, `!N` patches) in `source`.
///
/// References inside code spans, fenced code blocks, and existing link text are
/// not collected. Callers use this to pre-fetch summaries before rendering.
pub fn collect_refs(
source: &str,
) -> (
std::collections::BTreeSet<i64>,
std::collections::BTreeSet<i64>,
) {
let mut tickets = std::collections::BTreeSet::new();
let mut patches = std::collections::BTreeSet::new();
let mut code_depth = 0;
let mut link_depth = 0;
for event in Parser::new_ext(source, options()) {
match event {
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
Event::End(TagEnd::CodeBlock) => code_depth -= 1,
Event::Start(Tag::Link { .. }) => link_depth += 1,
Event::End(TagEnd::Link) => link_depth -= 1,
Event::Text(text) if code_depth == 0 && link_depth == 0 => {
let text = text.as_ref();
for (prefix, set) in [('#', &mut tickets), ('!', &mut patches)] {
let mut pos = 0;
while let Some(idx) = text[pos..].find(prefix) {
let abs = pos + idx;
let before_ok =
abs == 0 || !text.as_bytes()[abs - 1].is_ascii_alphanumeric();
if before_ok {
let digits_start = abs + 1;
let mut digits_end = digits_start;
while digits_end < text.len()
&& text.as_bytes()[digits_end].is_ascii_digit()
{
digits_end += 1;
}
if digits_end > digits_start {
let after_ok = digits_end == text.len()
|| !text.as_bytes()[digits_end].is_ascii_alphanumeric();
if after_ok
&& let Ok(n) = text[digits_start..digits_end].parse::<i64>()
{
set.insert(n);
}
}
}
pos = abs + 1;
}
}
}
_ => {}
}
}
(tickets, patches)
}
/// The markdown extensions the forge shows, on top of CommonMark.
///
/// Tables and footnotes are ordinary in a readme; strikethrough and task lists
/// are common enough that a readme using them would otherwise show the syntax.
/// Raw HTML is deliberately not gated behind an option: it is part of
/// CommonMark, and the sanitizer is what makes it safe.
fn options() -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options
}
/// The sanitizer permits only checkbox inputs — no text fields, no hidden
/// inputs, no way to submit a form. The `type` attribute is gated by value
/// rather than by name, so a non-checkbox input cannot be injected via raw
/// HTML.
fn sanitizer() -> ammonia::Builder<'static> {
let mut builder = ammonia::Builder::default();
builder
.add_tags(["input"])
.add_tag_attributes("input", ["checked", "disabled"])
.add_tag_attribute_values("input", "type", ["checkbox"]);
builder
}
/// Sanitize HTML content using the same rules as markdown rendering.
///
/// This is used for HTML received from remote ActivityPub instances, which may
/// contain untrusted HTML. Returns sanitized HTML safe for embedding.
pub fn sanitize_html(html: &str) -> String {
sanitizer().clean(html).to_string()
}
/// Renders `source` from markdown to HTML that is safe to embed.
pub fn render(source: &str) -> String {
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, Parser::new_ext(source, options()));
// The sanitizer is the last step rather than the first: it has to see the
// HTML the renderer produced, including any raw HTML it passed through.
sanitizer().clean(&rendered).to_string()
}
/// Renders `source` from markdown to HTML, rewriting relative file links to
/// tree URLs.
///
/// This is used when rendering markdown files in the repository browser, so
/// that links like `[docs](./docs/guide.md)` become navigable links to the tree
/// view.
pub fn render_with_repo_context(
source: &str,
owner: &str,
repo_slug: &str,
reference: &str,
file_path: &str,
summaries: &Summaries,
) -> String {
let file_dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
let events = Parser::new_ext(source, options()).map(|event| match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
let resolved = resolve_link(&dest_url, file_dir, owner, repo_slug, reference);
Event::Start(Tag::Link {
link_type,
dest_url: resolved.into(),
title,
id,
})
}
other => other,
});
// Apply shorthand expansion on top of link rewriting
let events = expand_shorthands(events, owner, repo_slug, summaries, None);
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, events);
sanitizer().clean(&rendered).to_string()
}
fn resolve_link(
dest: &str,
file_dir: &Path,
owner: &str,
repo_slug: &str,
reference: &str,
) -> String {
// Leave absolute URLs unchanged
if dest.starts_with("http://") || dest.starts_with("https://") || dest.starts_with("mailto:") {
return dest.to_string();
}
// Leave anchor links unchanged
if dest.starts_with('#') {
return dest.to_string();
}
// Handle absolute paths (starting with /)
if dest.starts_with('/') {
let path = dest.trim_start_matches('/');
return format!("/~{}/{}/tree/{}/{}", owner, repo_slug, reference, path);
}
// Resolve relative paths against the file's directory
let full_path = file_dir.join(dest);
let normalized = normalize_path(&full_path);
format!(
"/~{}/{}/tree/{}/{}",
owner, repo_slug, reference, normalized
)
}
fn normalize_path(path: &Path) -> String {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::Normal(c) => {
components.push(c.to_string_lossy().into_owned());
}
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {
// Skip .
}
_ => {}
}
}
components.join("/")
}
/// Expands shorthand references (#N for tickets, !N for patches) in markdown
/// text events.
///
/// Tracks code blocks and links to avoid expansion inside them.
/// Uses the provided summaries map to look up referenced tickets/patches.
fn expand_shorthands<'a, I>(
events: I,
owner: &str,
repo_slug: &str,
summaries: &'a Summaries,
absolute_base: Option<&str>,
) -> impl Iterator<Item = Event<'a>>
where
I: Iterator<Item = Event<'a>>,
{
let mut code_depth = 0;
let mut link_depth = 0;
events.flat_map(move |event| {
let mut output = Vec::new();
match event {
Event::Start(Tag::CodeBlock(_)) => {
code_depth += 1;
output.push(event);
}
Event::End(TagEnd::CodeBlock) => {
code_depth -= 1;
output.push(event);
}
Event::Start(Tag::Link { .. }) => {
link_depth += 1;
output.push(event);
}
Event::End(TagEnd::Link) => {
link_depth -= 1;
output.push(event);
}
Event::Text(text) if code_depth == 0 && link_depth == 0 => {
let text_str = text.as_ref();
let mut last_pos = 0;
let mut pos = 0;
// Scan for #N and !N
while pos < text_str.len() {
let ch = text_str.as_bytes()[pos];
if ch == b'#' || ch == b'!' {
let before_ok =
pos == 0 || !text_str.as_bytes()[pos - 1].is_ascii_alphanumeric();
if before_ok {
let digits_start = pos + 1;
let mut digits_end = digits_start;
while digits_end < text_str.len()
&& text_str.as_bytes()[digits_end].is_ascii_digit()
{
digits_end += 1;
}
if digits_end > digits_start {
let after_ok = digits_end == text_str.len()
|| !text_str.as_bytes()[digits_end].is_ascii_alphanumeric();
if after_ok
&& let Ok(n) = text_str[digits_start..digits_end].parse::<i64>()
{
// Check if this ref exists in summaries
let summary = if ch == b'#' {
summaries.tickets.get(&n)
} else {
summaries.patches.get(&n)
};
if let Some(summary) = summary {
// Emit text before this match
if pos > last_pos {
output.push(Event::Text(
text_str[last_pos..pos].to_string().into(),
));
}
// Build link
let dest_url = if ch == b'#' {
format!("/~{}/{}/tickets/{}", owner, repo_slug, n)
} else {
format!("/~{}/{}/patches/{}", owner, repo_slug, n)
};
let dest_url = if let Some(base) = absolute_base {
format!("{}{}", base, dest_url)
} else {
dest_url
};
output.push(Event::Start(Tag::Link {
link_type: pulldown_cmark::LinkType::Inline,
dest_url: dest_url.into(),
title: summary.clone().into(),
id: "".into(),
}));
output.push(Event::Text(
text_str[pos..digits_end].to_string().into(),
));
output.push(Event::End(TagEnd::Link));
last_pos = digits_end;
pos = digits_end;
continue;
}
}
}
}
}
pos += 1;
}
// Emit remaining text
if last_pos < text_str.len() {
output.push(Event::Text(text_str[last_pos..].to_string().into()));
}
if output.is_empty() {
output.push(Event::Text("".into()));
}
}
_ => output.push(event),
}
output
})
}
/// Renders `source` from markdown to HTML, expanding shorthand references
/// (#N for tickets, !N for patches) with relative URLs.
pub fn render_in_repo(source: &str, owner: &str, repo_slug: &str, summaries: &Summaries) -> String {
let events = expand_shorthands(
Parser::new_ext(source, options()),
owner,
repo_slug,
summaries,
None,
);
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, events);
sanitizer().clean(&rendered).to_string()
}
/// Renders `source` from markdown to HTML, expanding shorthand references
/// (#N for tickets, !N for patches) with absolute URLs for ActivityPub content.
pub fn render_in_repo_abs(
source: &str,
base_url: &str,
owner: &str,
repo_slug: &str,
summaries: &Summaries,
) -> String {
let events = expand_shorthands(
Parser::new_ext(source, options()),
owner,
repo_slug,
summaries,
Some(base_url),
);
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, events);
sanitizer().clean(&rendered).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_markdown_is_rendered() {
let rendered = render("# Notes\n\n- one\n- two\n\n*emphasis*\n");
assert!(rendered.contains("<h1>Notes</h1>"), "{rendered}");
assert!(rendered.contains("<li>one</li>"), "{rendered}");
assert!(rendered.contains("<em>emphasis</em>"), "{rendered}");
}
#[test]
fn raw_html_is_removed() {
// A readme is untrusted input. Whatever a browser could execute from it
// cannot survive rendering.
for (source, forbidden) in [
("<script>alert(1)</script>", "<script"),
("<img src=x onerror=alert(1)>", "onerror"),
("<a href=\"javascript:alert(1)\">click</a>", "javascript:"),
("<style>body{display:none}</style>", "<style"),
("<iframe src=\"https://example.com\"></iframe>", "<iframe"),
] {
let rendered = render(source);
assert!(
!rendered.contains(forbidden),
"{source:?} should not survive as {forbidden:?}, got:\n{rendered}"
);
}
}
#[test]
fn a_javascript_link_written_as_markdown_is_removed() {
let rendered = render("[click](javascript:alert(1))");
assert!(
!rendered.contains("javascript:"),
"a markdown link should not carry a script, got:\n{rendered}"
);
}
#[test]
fn an_ordinary_link_survives() {
let rendered = render("[home](https://example.com/)");
assert!(
rendered.contains("href=\"https://example.com/\""),
"{rendered}"
);
}
#[test]
fn a_relative_link_is_rewritten_to_tree_url() {
let rendered = render_with_repo_context(
"[guide](./docs/guide.md)",
"alice",
"myrepo",
"main",
"README.md",
&Summaries::default(),
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/guide.md\""),
"{rendered}"
);
}
#[test]
fn a_relative_link_from_subdirectory_is_resolved() {
let rendered = render_with_repo_context(
"[sibling](./sibling.md)",
"alice",
"myrepo",
"main",
"docs/guide.md",
&Summaries::default(),
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/sibling.md\""),
"{rendered}"
);
}
#[test]
fn an_absolute_path_link_is_rewritten_to_tree_url() {
let rendered = render_with_repo_context(
"[root](/docs/guide.md)",
"alice",
"myrepo",
"main",
"README.md",
&Summaries::default(),
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/guide.md\""),
"{rendered}"
);
}
#[test]
fn external_links_are_not_rewritten() {
let rendered = render_with_repo_context(
"[external](https://example.com/path)",
"alice",
"myrepo",
"main",
"README.md",
&Summaries::default(),
);
assert!(
rendered.contains("href=\"https://example.com/path\""),
"{rendered}"
);
}
#[test]
fn unchecked_task_list_is_rendered() {
let rendered = render("- [ ] todo\n- [ ] another\n");
assert!(
rendered.contains("<input"),
"checkbox input should survive:\n{rendered}"
);
assert!(
rendered.contains("type=\"checkbox\""),
"type should be checkbox:\n{rendered}"
);
assert!(
rendered.contains("disabled"),
"checkbox should be disabled:\n{rendered}"
);
}
#[test]
fn checked_task_list_is_rendered() {
let rendered = render("- [x] done\n- [X] also done\n");
assert!(
rendered.contains("<input"),
"checkbox input should survive:\n{rendered}"
);
assert!(
rendered.contains("type=\"checkbox\""),
"type should be checkbox:\n{rendered}"
);
assert!(
rendered.contains("checked"),
"checkbox should be checked:\n{rendered}"
);
}
#[test]
fn raw_input_is_stripped() {
// Only checkbox inputs from task lists should survive; raw HTML inputs
// are not allowed.
for (source, forbidden) in [
("<input type=\"text\">", "type=\"text\""),
("<input type=\"hidden\" name=\"x\">", "type=\"hidden\""),
("<input formaction=\"https://example.com\">", "formaction"),
] {
let rendered = render(source);
assert!(
!rendered.contains(forbidden),
"{source:?} should not survive as {forbidden:?}, got:\n{rendered}"
);
}
}
#[test]
fn ticket_shorthand_expands_when_present() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix the bug".to_string());
let rendered = render_in_repo("Fixed in #1", "alice", "myrepo", &summaries);
assert!(
rendered.contains(r#"<a href="/~alice/myrepo/tickets/1" title="Fix the bug" rel="noopener noreferrer">#1</a>"#),
"{rendered}"
);
}
#[test]
fn patch_shorthand_expands_when_present() {
let mut summaries = Summaries::default();
summaries.patches.insert(42, "Add feature X".to_string());
let rendered = render_in_repo("See !42", "alice", "myrepo", &summaries);
assert!(
rendered
.contains(r#"<a href="/~alice/myrepo/patches/42" title="Add feature X" rel="noopener noreferrer">!42</a>"#),
"{rendered}"
);
}
#[test]
fn missing_reference_stays_literal() {
let summaries = Summaries::default();
let rendered = render_in_repo("Try #999", "alice", "myrepo", &summaries);
assert!(!rendered.contains("<a"), "{rendered}");
assert!(rendered.contains("#999"), "{rendered}");
}
#[test]
fn shorthands_in_code_spans_do_not_expand() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo("Use `#1` syntax", "alice", "myrepo", &summaries);
assert!(!rendered.contains("<a"), "{rendered}");
assert!(rendered.contains("<code>#1</code>"), "{rendered}");
}
#[test]
fn shorthands_in_code_blocks_do_not_expand() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo("```\n#1\n```", "alice", "myrepo", &summaries);
assert!(!rendered.contains("<a"), "{rendered}");
}
#[test]
fn shorthands_inside_link_text_do_not_expand() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo(
"[see #1](https://example.com)",
"alice",
"myrepo",
&summaries,
);
// Should have one link to example.com, not nested links
assert_eq!(rendered.matches("<a ").count(), 1, "{rendered}");
assert!(
rendered.contains(r#"href="https://example.com""#),
"{rendered}"
);
}
#[test]
fn word_boundary_before_is_respected() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo("foo#1", "alice", "myrepo", &summaries);
assert!(!rendered.contains("<a"), "{rendered}");
}
#[test]
fn word_boundary_after_is_respected() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo("#1abc", "alice", "myrepo", &summaries);
assert!(!rendered.contains("<a"), "{rendered}");
}
#[test]
fn shorthands_with_punctuation_expand() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered = render_in_repo("Fixed (#1).", "alice", "myrepo", &summaries);
assert!(rendered.contains("<a"), "{rendered}");
assert!(rendered.contains("#1"), "{rendered}");
}
#[test]
fn multiple_shorthands_in_one_paragraph() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "First".to_string());
summaries.tickets.insert(2, "Second".to_string());
summaries.patches.insert(10, "Patch ten".to_string());
let rendered = render_in_repo("See #1, #2, and !10", "alice", "myrepo", &summaries);
assert_eq!(rendered.matches("<a ").count(), 3, "{rendered}");
assert!(
rendered.contains(r#"/~alice/myrepo/tickets/1"#),
"{rendered}"
);
assert!(
rendered.contains(r#"/~alice/myrepo/tickets/2"#),
"{rendered}"
);
assert!(
rendered.contains(r#"/~alice/myrepo/patches/10"#),
"{rendered}"
);
}
#[test]
fn absolute_urls_for_activitypub() {
let mut summaries = Summaries::default();
summaries.tickets.insert(1, "Fix".to_string());
let rendered =
render_in_repo_abs("#1", "https://example.com", "alice", "myrepo", &summaries);
assert!(
rendered.contains(r#"href="https://example.com/~alice/myrepo/tickets/1""#),
"{rendered}"
);
}
#[test]
fn collect_refs_finds_references() {
let source = "Fixed #1 and #2, see !10, but not `#3` or #999abc";
let (tickets, patches) = collect_refs(source);
assert_eq!(
tickets,
[1, 2]
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
);
assert_eq!(
patches,
[10].into_iter().collect::<std::collections::BTreeSet<_>>()
);
}
#[test]
fn collect_refs_skips_code_blocks() {
let source = "See #1\n\n```\n#2\n```\n\nAnd `#3`";
let (tickets, _) = collect_refs(source);
assert_eq!(
tickets,
[1].into_iter().collect::<std::collections::BTreeSet<_>>()
);
}
#[test]
fn collect_refs_skips_link_text() {
let source = "[see #1](https://example.com) and #2";
let (tickets, _) = collect_refs(source);
assert_eq!(
tickets,
[2].into_iter().collect::<std::collections::BTreeSet<_>>()
);
}
}