540871a8cdc2c7df89bd3c0f5eb39387359db062 / frogue / src / markdown.rs
Raw
// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
//! 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.
pub(crate) 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 summaries = Summaries {
tickets: std::collections::HashMap::new(),
patches: std::collections::HashMap::new(),
};
let events = expand_shorthands(Parser::new_ext(source, options()), "", "", &summaries, None);
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, events);
// 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 is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn scan_while(text: &str, start: usize, predicate: fn(u8) -> bool) -> usize {
let bytes = text.as_bytes();
let mut end = start;
while end < bytes.len() && predicate(bytes[end]) {
end += 1;
}
end
}
fn is_username_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
}
fn is_host_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')
}
fn is_hashtag_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
/// Returns the end offset, destination URL, and link title for a shorthand
/// token starting at `pos`, if the token is valid and linkable.
fn shorthand_at(
text: &str,
pos: usize,
ch: u8,
owner: &str,
repo_slug: &str,
summaries: &Summaries,
absolute_base: Option<&str>,
) -> Option<(usize, String, String)> {
let bytes = text.as_bytes();
if ch == b'@' {
let username_start = pos + 1;
let username_end = scan_while(text, username_start, is_username_byte);
if username_end == username_start {
return None;
}
let username = &text[username_start..username_end];
let (host, end) = if bytes.get(username_end) == Some(&b'@') {
let host_start = username_end + 1;
let host_end = scan_while(text, host_start, is_host_byte);
if host_end == host_start {
(None, username_end)
} else {
(Some(&text[host_start..host_end]), host_end)
}
} else {
(None, username_end)
};
let dest_url = match host {
Some(host) => format!("https://{host}/@{username}"),
None => {
let path = format!("/~{username}");
match absolute_base {
Some(base) => format!("{base}{path}"),
None => path,
}
}
};
return Some((end, dest_url, String::new()));
}
if ch == b'#' || ch == b'!' {
// Try ticket/patch ref first (#1, !42)
let digits_start = pos + 1;
let digits_end = scan_while(text, digits_start, |b| b.is_ascii_digit());
if digits_end > digits_start {
let boundary_ok =
digits_end == text.len() || !is_word_byte(text.as_bytes()[digits_end]);
if boundary_ok && let Ok(n) = text[digits_start..digits_end].parse::<i64>() {
let (set, route) = match ch {
b'#' => (&summaries.tickets, "tickets"),
b'!' => (&summaries.patches, "patches"),
_ => return None,
};
if let Some(summary) = set.get(&n) {
let path = format!("/~{owner}/{repo_slug}/{route}/{n}");
let dest_url = match absolute_base {
Some(base) => format!("{base}{path}"),
None => path,
};
return Some((digits_end, dest_url, summary.clone()));
}
}
// Not a valid ticket/patch ref, so don't treat as hashtag either
// (e.g., #1abc should not become a hashtag)
return None;
}
// Try hashtag (#ActivityPub)
let tag_start = pos + 1;
let tag_end = scan_while(text, tag_start, is_hashtag_byte);
if tag_end > tag_start && !text[tag_start..tag_end].bytes().all(|b| b.is_ascii_digit()) {
let tag = &text[tag_start..tag_end];
let path = format!("/tags/{tag}");
let dest_url = match absolute_base {
Some(base) => format!("{base}{path}"),
None => path,
};
return Some((tag_end, dest_url, String::new()));
}
}
None
}
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, !N, @mentions and #hashtags.
while pos < text_str.len() {
let ch = text_str.as_bytes()[pos];
if ch == b'!' || ch == b'#' || ch == b'@' {
let before_ok = pos == 0 || !is_word_byte(text_str.as_bytes()[pos - 1]);
if before_ok
&& let Some((end, dest_url, title)) = shorthand_at(
text_str,
pos,
ch,
owner,
repo_slug,
summaries,
absolute_base,
)
{
if pos > last_pos {
output
.push(Event::Text(text_str[last_pos..pos].to_string().into()));
}
output.push(Event::Start(Tag::Link {
link_type: pulldown_cmark::LinkType::Inline,
dest_url: dest_url.into(),
title: title.into(),
id: "".into(),
}));
output.push(Event::Text(text_str[pos..end].to_string().into()));
output.push(Event::End(TagEnd::Link));
last_pos = end;
pos = 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<_>>()
);
}
}