a73x

673dbb4a

feat(server): readme size caps, UTF-8 strict, blob link

alex emery   2026-04-13 05:37

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/server/http/repo/readme.rs b/src/server/http/repo/readme.rs
index 89289d4..6672521 100644
--- a/src/server/http/repo/readme.rs
+++ b/src/server/http/repo/readme.rs
@@ -10,35 +10,72 @@ pub struct RenderedReadme {
    pub html: String,
}

const MAX_BLOB_BYTES: usize = 512 * 1024;
const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;

/// Load and render the README at the root of HEAD's tree.
///
/// Returns `None` when no README is present, the blob is binary or invalid
/// UTF-8, or any unexpected git2 error occurs. The README must never break
/// the overview page — failures degrade silently to "no README".
pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
pub fn load_readme(
    repo: &Repository,
    repo_name: &str,
    branch: &str,
) -> Option<RenderedReadme> {
    let head = repo.head().ok()?;
    let commit = head.peel_to_commit().ok()?;
    let tree = commit.tree().ok()?;
    let entry = find_readme_entry(&tree)?;

    let blob = repo.find_blob(entry.oid).ok()?;
    let blob = match repo.find_blob(entry.oid) {
        Ok(b) => b,
        Err(err) => {
            tracing::warn!(repo = repo_name, error = %err, "failed to load readme blob");
            return None;
        }
    };

    if blob.is_binary() {
        return None;
    }

    if blob.size() > MAX_BLOB_BYTES {
        return Some(RenderedReadme {
            html: too_large_notice_with_repo(repo_name, branch, &entry.name),
        });
    }

    let text = std::str::from_utf8(blob.content()).ok()?;

    let html = match entry.kind {
        ReadmeKind::Markdown => render_markdown(text),
        ReadmeKind::Markdown => {
            let rendered = render_markdown(text);
            if rendered.len() > MAX_HTML_BYTES {
                too_large_notice_with_repo(repo_name, branch, &entry.name)
            } else {
                rendered
            }
        }
        ReadmeKind::Plain => render_plain(text),
    };
    Some(RenderedReadme { html })
}

fn too_large_notice_with_repo(repo_name: &str, branch: &str, file_name: &str) -> String {
    format!(
        "<p><em>README too large to render. \
         <a href=\"/{repo}/blob/{branch}/{name}\">View raw</a>.</em></p>",
        repo = html_escape(repo_name),
        branch = html_escape(branch),
        name = html_escape(file_name),
    )
}

/// One root-tree entry. Carries the resolved category so the renderer can
/// branch on type without re-parsing the filename.
#[derive(Debug)]
struct ReadmeEntry {
    #[allow(dead_code)]
    name: String,
    oid: git2::Oid,
    kind: ReadmeKind,
@@ -140,6 +177,13 @@ mod tests {
    use git2::Repository;
    use tempfile::TempDir;

    fn load(repo: &Repository) -> Option<RenderedReadme> {
        let branch = repo.head().ok()
            .and_then(|h| h.shorthand().map(String::from))
            .unwrap_or_else(|| "main".to_string());
        load_readme(repo, "test-repo", &branch)
    }

    /// Build an empty git repo with a single commit containing the given
    /// (path, contents) blobs at the root tree. Returns the repo and the
    /// tempdir (kept alive by the caller).
@@ -167,20 +211,20 @@ mod tests {
    #[test]
    fn no_readme_returns_none() {
        let (repo, _tmp) = repo_with_files(&[("lib.rs", b"fn main() {}")]);
        assert!(load_readme(&repo).is_none());
        assert!(load(&repo).is_none());
    }

    #[test]
    fn finds_uppercase_readme_md() {
        let (repo, _tmp) = repo_with_files(&[("README.md", b"# Title\n\nbody\n")]);
        let r = load_readme(&repo).expect("README found");
        let r = load(&repo).expect("README found");
        assert!(r.html.contains("<h1>Title</h1>"), "got: {}", r.html);
    }

    #[test]
    fn finds_mixed_case_readme_md() {
        let (repo, _tmp) = repo_with_files(&[("Readme.MD", b"# T\n")]);
        assert!(load_readme(&repo).is_some());
        assert!(load(&repo).is_some());
    }

    #[test]
@@ -189,7 +233,7 @@ mod tests {
            ("README.md", b"# md\n"),
            ("README.txt", b"plain"),
        ]);
        let r = load_readme(&repo).unwrap();
        let r = load(&repo).unwrap();
        assert!(r.html.contains("<h1>md</h1>"));
        assert!(!r.html.contains("plain"));
    }
@@ -200,7 +244,7 @@ mod tests {
            ("README", b"plain readme"),
            ("README.txt", b"plain txt"),
        ]);
        let r = load_readme(&repo).unwrap();
        let r = load(&repo).unwrap();
        assert!(r.html.contains("plain readme"));
        assert!(!r.html.contains("plain txt"));
    }
@@ -211,7 +255,7 @@ mod tests {
            ("README.md", b"# md\n"),
            ("readme.txt", b"plain"),
        ]);
        assert!(load_readme(&repo).unwrap().html.contains("<h1>md</h1>"));
        assert!(load(&repo).unwrap().html.contains("<h1>md</h1>"));
    }

    #[test]
@@ -242,7 +286,7 @@ mod tests {
        let tree = repo.find_tree(root_oid).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

        let r = load_readme(&repo).expect("root README must be found");
        let r = load(&repo).expect("root README must be found");
        assert!(r.html.contains("actual root readme"));
        assert!(!r.html.contains("nested wins"));
    }
@@ -252,7 +296,7 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README", b"<script>alert(1)</script>\nhello"),
        ]);
        let r = load_readme(&repo).unwrap();
        let r = load(&repo).unwrap();
        assert!(r.html.starts_with("<pre>"));
        assert!(r.html.contains("&lt;script&gt;"));
        assert!(!r.html.contains("<script>"));
@@ -274,7 +318,7 @@ mod tests {
        let tree = repo.find_tree(root_oid).unwrap();
        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();

        assert!(load_readme(&repo).is_none());
        assert!(load(&repo).is_none());
    }

    #[test]
@@ -284,7 +328,7 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"# t\n\n<script>alert(1)</script>\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        let html = load(&repo).unwrap().html;
        assert!(!html.contains("<script>"), "got: {}", html);
    }

@@ -295,7 +339,7 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"[click](javascript:alert(1))\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        let html = load(&repo).unwrap().html;
        assert!(!html.contains("javascript:"), "got: {}", html);
    }

@@ -306,7 +350,7 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<img src=\"https://x/y.png\" onerror=\"alert(1)\">\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        let html = load(&repo).unwrap().html;
        assert!(!html.contains("onerror"), "got: {}", html);
    }

@@ -317,7 +361,7 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<iframe src=\"https://evil.example/\"></iframe>\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        let html = load(&repo).unwrap().html;
        assert!(!html.contains("<iframe"), "got: {}", html);
    }

@@ -326,8 +370,61 @@ mod tests {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<img src=\"data:image/png;base64,AAAA\">\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        let html = load(&repo).unwrap().html;
        // Either the whole <img> is dropped or the src attr is gone.
        assert!(!html.contains("data:"), "got: {}", html);
    }

    #[test]
    fn empty_readme_renders_empty_body() {
        let (repo, _tmp) = repo_with_files(&[("README.md", b"")]);
        let r = load(&repo).expect("present-but-empty is Some");
        // ammonia of empty markdown is the empty string; assert it's not the
        // too-large notice and not None.
        assert!(!r.html.contains("too large"));
    }

    #[test]
    fn oversized_blob_returns_too_large_notice() {
        let big = vec![b'x'; 600 * 1024];
        let (repo, _tmp) = repo_with_files(&[("README.md", &big)]);
        let r = load(&repo).unwrap();
        assert!(r.html.contains("too large"));
        assert!(r.html.contains("/blob/"));
        assert!(r.html.contains("README.md"));
    }

    #[test]
    fn markdown_bomb_post_render_cap_trips() {
        // Code-span paragraphs: "`a`\n\n" (5 bytes each) expand to
        // "<p><code>a</code></p>\n" (~22 bytes each) — a 4.4x ratio.
        // At 100_000 reps: source = 500_000 bytes < 512 KiB (524_288),
        // rendered ≈ 2_200_000 bytes > 2 MiB (2_097_152).
        // Calibrated empirically: ratio confirmed at 4.40x.
        let mut src = String::new();
        for _ in 0..100_000usize {
            src.push_str("`a`\n\n");
        }
        assert!(src.len() < MAX_BLOB_BYTES, "source {} >= blob cap {}", src.len(), MAX_BLOB_BYTES);
        let (repo, _tmp) = repo_with_files(&[("README.md", src.as_bytes())]);
        let r = load(&repo).unwrap();
        assert!(r.html.contains("too large"), "expected bomb to trip cap");
    }

    #[test]
    fn binary_blob_returns_none() {
        let (repo, _tmp) = repo_with_files(&[("README.md", &[0u8, 1, 2, 3, 0xff, 0xfe])]);
        assert!(load(&repo).is_none());
    }

    #[test]
    fn invalid_utf8_returns_none() {
        // Mostly valid text + a stray 0x80 byte. Not flagged as binary by git2's
        // heuristic (no NULs), but not valid UTF-8 either.
        let mut bytes = Vec::from(&b"hello world\nmore text\n"[..]);
        bytes.push(0x80);
        bytes.extend_from_slice(b"\nmore\n");
        let (repo, _tmp) = repo_with_files(&[("README.md", &bytes)]);
        assert!(load(&repo).is_none());
    }
}