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