🐸 Frogue(Fed)

~ololduck/Frogue

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<_>>()
        );
    }
}