a73x

c5c9e075

Render the README on the repo overview page

a73x   2026-08-08 16:57

Commit message
Render the README on the repo overview page

Cargo.lock
Old New
@@ -72,6 +72,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
72 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 72 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
73 73
74 [[package]] 74 [[package]]
75 name = "ammonia"
76 version = "4.1.2"
77 source = "registry+https://github.com/rust-lang/crates.io-index"
78 checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
79 dependencies = [
80 "cssparser",
81 "html5ever",
82 "maplit",
83 "tendril",
84 "url",
85 ]
86
87 [[package]]
75 name = "android_system_properties" 88 name = "android_system_properties"
76 version = "0.1.5" 89 version = "0.1.5"
77 source = "registry+https://github.com/rust-lang/crates.io-index" 90 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -813,6 +826,29 @@ dependencies = [
813 ] 826 ]
814 827
815 [[package]] 828 [[package]]
829 name = "cssparser"
830 version = "0.35.0"
831 source = "registry+https://github.com/rust-lang/crates.io-index"
832 checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
833 dependencies = [
834 "cssparser-macros",
835 "dtoa-short",
836 "itoa",
837 "phf",
838 "smallvec",
839 ]
840
841 [[package]]
842 name = "cssparser-macros"
843 version = "0.6.1"
844 source = "registry+https://github.com/rust-lang/crates.io-index"
845 checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
846 dependencies = [
847 "quote",
848 "syn 2.0.117",
849 ]
850
851 [[package]]
816 name = "ctr" 852 name = "ctr"
817 version = "0.9.2" 853 version = "0.9.2"
818 source = "registry+https://github.com/rust-lang/crates.io-index" 854 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1010,6 +1046,21 @@ dependencies = [
1010 ] 1046 ]
1011 1047
1012 [[package]] 1048 [[package]]
1049 name = "dtoa"
1050 version = "1.0.11"
1051 source = "registry+https://github.com/rust-lang/crates.io-index"
1052 checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1053
1054 [[package]]
1055 name = "dtoa-short"
1056 version = "0.3.5"
1057 source = "registry+https://github.com/rust-lang/crates.io-index"
1058 checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1059 dependencies = [
1060 "dtoa",
1061 ]
1062
1063 [[package]]
1013 name = "ecdsa" 1064 name = "ecdsa"
1014 version = "0.16.9" 1065 version = "0.16.9"
1015 source = "registry+https://github.com/rust-lang/crates.io-index" 1066 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1211,6 +1262,16 @@ dependencies = [
1211 ] 1262 ]
1212 1263
1213 [[package]] 1264 [[package]]
1265 name = "futf"
1266 version = "0.1.5"
1267 source = "registry+https://github.com/rust-lang/crates.io-index"
1268 checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
1269 dependencies = [
1270 "mac",
1271 "new_debug_unreachable",
1272 ]
1273
1274 [[package]]
1214 name = "futures" 1275 name = "futures"
1215 version = "0.3.32" 1276 version = "0.3.32"
1216 source = "registry+https://github.com/rust-lang/crates.io-index" 1277 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1361,6 +1422,7 @@ dependencies = [
1361 name = "git-collab" 1422 name = "git-collab"
1362 version = "0.1.0" 1423 version = "0.1.0"
1363 dependencies = [ 1424 dependencies = [
1425 "ammonia",
1364 "askama", 1426 "askama",
1365 "askama_web", 1427 "askama_web",
1366 "async-trait", 1428 "async-trait",
@@ -1376,6 +1438,7 @@ dependencies = [
1376 "ed25519-dalek", 1438 "ed25519-dalek",
1377 "git2", 1439 "git2",
1378 "proptest", 1440 "proptest",
1441 "pulldown-cmark",
1379 "rand_core 0.6.4", 1442 "rand_core 0.6.4",
1380 "ratatui", 1443 "ratatui",
1381 "russh", 1444 "russh",
@@ -1501,6 +1564,17 @@ dependencies = [
1501 ] 1564 ]
1502 1565
1503 [[package]] 1566 [[package]]
1567 name = "html5ever"
1568 version = "0.35.0"
1569 source = "registry+https://github.com/rust-lang/crates.io-index"
1570 checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
1571 dependencies = [
1572 "log",
1573 "markup5ever",
1574 "match_token",
1575 ]
1576
1577 [[package]]
1504 name = "http" 1578 name = "http"
1505 version = "1.4.0" 1579 version = "1.4.0"
1506 source = "registry+https://github.com/rust-lang/crates.io-index" 1580 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1971,6 +2045,12 @@ dependencies = [
1971 ] 2045 ]
1972 2046
1973 [[package]] 2047 [[package]]
2048 name = "mac"
2049 version = "0.1.1"
2050 source = "registry+https://github.com/rust-lang/crates.io-index"
2051 checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
2052
2053 [[package]]
1974 name = "mac_address" 2054 name = "mac_address"
1975 version = "1.1.8" 2055 version = "1.1.8"
1976 source = "registry+https://github.com/rust-lang/crates.io-index" 2056 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1981,6 +2061,34 @@ dependencies = [
1981 ] 2061 ]
1982 2062
1983 [[package]] 2063 [[package]]
2064 name = "maplit"
2065 version = "1.0.2"
2066 source = "registry+https://github.com/rust-lang/crates.io-index"
2067 checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2068
2069 [[package]]
2070 name = "markup5ever"
2071 version = "0.35.0"
2072 source = "registry+https://github.com/rust-lang/crates.io-index"
2073 checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2074 dependencies = [
2075 "log",
2076 "tendril",
2077 "web_atoms",
2078 ]
2079
2080 [[package]]
2081 name = "match_token"
2082 version = "0.35.0"
2083 source = "registry+https://github.com/rust-lang/crates.io-index"
2084 checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2085 dependencies = [
2086 "proc-macro2",
2087 "quote",
2088 "syn 2.0.117",
2089 ]
2090
2091 [[package]]
1984 name = "matchit" 2092 name = "matchit"
1985 version = "0.8.4" 2093 version = "0.8.4"
1986 source = "registry+https://github.com/rust-lang/crates.io-index" 2094 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2048,6 +2156,12 @@ dependencies = [
2048 ] 2156 ]
2049 2157
2050 [[package]] 2158 [[package]]
2159 name = "new_debug_unreachable"
2160 version = "1.0.6"
2161 source = "registry+https://github.com/rust-lang/crates.io-index"
2162 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2163
2164 [[package]]
2051 name = "nix" 2165 name = "nix"
2052 version = "0.29.0" 2166 version = "0.29.0"
2053 source = "registry+https://github.com/rust-lang/crates.io-index" 2167 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2563,6 +2677,12 @@ dependencies = [
2563 ] 2677 ]
2564 2678
2565 [[package]] 2679 [[package]]
2680 name = "precomputed-hash"
2681 version = "0.1.1"
2682 source = "registry+https://github.com/rust-lang/crates.io-index"
2683 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2684
2685 [[package]]
2566 name = "prettyplease" 2686 name = "prettyplease"
2567 version = "0.2.37" 2687 version = "0.2.37"
2568 source = "registry+https://github.com/rust-lang/crates.io-index" 2688 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2610,6 +2730,24 @@ dependencies = [
2610 ] 2730 ]
2611 2731
2612 [[package]] 2732 [[package]]
2733 name = "pulldown-cmark"
2734 version = "0.12.2"
2735 source = "registry+https://github.com/rust-lang/crates.io-index"
2736 checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
2737 dependencies = [
2738 "bitflags 2.11.0",
2739 "memchr",
2740 "pulldown-cmark-escape",
2741 "unicase",
2742 ]
2743
2744 [[package]]
2745 name = "pulldown-cmark-escape"
2746 version = "0.11.0"
2747 source = "registry+https://github.com/rust-lang/crates.io-index"
2748 checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
2749
2750 [[package]]
2613 name = "quick-error" 2751 name = "quick-error"
2614 version = "1.2.3" 2752 version = "1.2.3"
2615 source = "registry+https://github.com/rust-lang/crates.io-index" 2753 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3413,6 +3551,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3413 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 3551 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
3414 3552
3415 [[package]] 3553 [[package]]
3554 name = "string_cache"
3555 version = "0.8.9"
3556 source = "registry+https://github.com/rust-lang/crates.io-index"
3557 checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
3558 dependencies = [
3559 "new_debug_unreachable",
3560 "parking_lot",
3561 "phf_shared",
3562 "precomputed-hash",
3563 "serde",
3564 ]
3565
3566 [[package]]
3567 name = "string_cache_codegen"
3568 version = "0.5.4"
3569 source = "registry+https://github.com/rust-lang/crates.io-index"
3570 checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
3571 dependencies = [
3572 "phf_generator",
3573 "phf_shared",
3574 "proc-macro2",
3575 "quote",
3576 ]
3577
3578 [[package]]
3416 name = "strsim" 3579 name = "strsim"
3417 version = "0.11.1" 3580 version = "0.11.1"
3418 source = "registry+https://github.com/rust-lang/crates.io-index" 3581 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3498,6 +3661,17 @@ dependencies = [
3498 ] 3661 ]
3499 3662
3500 [[package]] 3663 [[package]]
3664 name = "tendril"
3665 version = "0.4.3"
3666 source = "registry+https://github.com/rust-lang/crates.io-index"
3667 checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
3668 dependencies = [
3669 "futf",
3670 "mac",
3671 "utf-8",
3672 ]
3673
3674 [[package]]
3501 name = "terminfo" 3675 name = "terminfo"
3502 version = "0.9.0" 3676 version = "0.9.0"
3503 source = "registry+https://github.com/rust-lang/crates.io-index" 3677 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3846,6 +4020,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3846 checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 4020 checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
3847 4021
3848 [[package]] 4022 [[package]]
4023 name = "unicase"
4024 version = "2.9.0"
4025 source = "registry+https://github.com/rust-lang/crates.io-index"
4026 checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
4027
4028 [[package]]
3849 name = "unicode-ident" 4029 name = "unicode-ident"
3850 version = "1.0.24" 4030 version = "1.0.24"
3851 source = "registry+https://github.com/rust-lang/crates.io-index" 4031 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3903,6 +4083,12 @@ dependencies = [
3903 ] 4083 ]
3904 4084
3905 [[package]] 4085 [[package]]
4086 name = "utf-8"
4087 version = "0.7.6"
4088 source = "registry+https://github.com/rust-lang/crates.io-index"
4089 checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
4090
4091 [[package]]
3906 name = "utf8_iter" 4092 name = "utf8_iter"
3907 version = "1.0.4" 4093 version = "1.0.4"
3908 source = "registry+https://github.com/rust-lang/crates.io-index" 4094 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4096,6 +4282,18 @@ dependencies = [
4096 ] 4282 ]
4097 4283
4098 [[package]] 4284 [[package]]
4285 name = "web_atoms"
4286 version = "0.1.3"
4287 source = "registry+https://github.com/rust-lang/crates.io-index"
4288 checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
4289 dependencies = [
4290 "phf",
4291 "phf_codegen",
4292 "string_cache",
4293 "string_cache_codegen",
4294 ]
4295
4296 [[package]]
4099 name = "wezterm-bidi" 4297 name = "wezterm-bidi"
4100 version = "0.2.3" 4298 version = "0.2.3"
4101 source = "registry+https://github.com/rust-lang/crates.io-index" 4299 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml
Old New
@@ -29,6 +29,8 @@ axum = "0.8"
29 tokio = { version = "1", features = ["full"] } 29 tokio = { version = "1", features = ["full"] }
30 askama = "0.15" 30 askama = "0.15"
31 askama_web = { version = "0.15", features = ["axum-0.8"] } 31 askama_web = { version = "0.15", features = ["axum-0.8"] }
32 pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
33 ammonia = "4"
32 russh = "0.46" 34 russh = "0.46"
33 russh-keys = "0.46" 35 russh-keys = "0.46"
34 toml_edit = { version = "0.22", features = ["serde"] } 36 toml_edit = { version = "0.22", features = ["serde"] }
docs/superpowers/plans/2026-04-12-render-readme-on-overview.md
Old New
@@ -0,0 +1,924 @@
1 # Render README on Repo Overview — Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Render the repository's README on `GET /{repo}` above the existing patches/issues/recent-commits sections, with markdown rendering, sanitization, and a hard size cap.
6
7 **Architecture:** A new pure module `src/server/http/repo/readme.rs` walks HEAD's root tree, finds a README by case-insensitive name (`README.md` → `README` → `README.txt`), renders markdown via `pulldown-cmark` + `ammonia` (with `<img>` URL schemes restricted to `http`/`https`), and returns `Option<RenderedReadme>`. The overview handler calls it and the template renders the safe HTML inside a bordered card.
8
9 **Tech Stack:** Rust 2021, axum 0.8, askama 0.15, git2 0.19, pulldown-cmark 0.12, ammonia 4. Tests use `tempfile` and the existing `tests/common::ServerHarness`.
10
11 **Spec:** `docs/superpowers/specs/2026-04-12-render-readme-on-overview-design.md`
12
13 ---
14
15 ## File Structure
16
17 | File | Action | Responsibility |
18 |---|---|---|
19 | `Cargo.toml` | Modify | Add `pulldown-cmark`, `ammonia` deps |
20 | `src/server/http/repo/readme.rs` | Create | All README lookup, decoding, rendering, sanitization. Pure (no Axum / state). Contains its own `#[cfg(test)] mod tests`. |
21 | `src/server/http/repo/mod.rs` | Modify | `mod readme;` + `pub use readme::RenderedReadme;` if needed |
22 | `src/server/http/repo/overview.rs` | Modify | Call `readme::load_readme(&repo)`, add `readme: Option<RenderedReadme>` field to `OverviewTemplate` |
23 | `src/server/http/templates/repo_overview.html` | Modify | Render the readme card above the patches/issues grid |
24 | `tests/server_behavior_test.rs` | Modify | One end-to-end integration test against the running router |
25
26 ---
27
28 ## Conventions for every task
29
30 - **TDD**: write the failing test, watch it fail, write the minimal code, watch it pass, commit.
31 - **Test execution**: run only the specific test under development with
32 `cargo test --test <crate> <test_name> -- --nocapture` or
33 `cargo test -p git-collab readme:: -- --nocapture` for unit tests inside `readme.rs`.
34 - **Commits**: small, conventional. Co-author trailer is added by the harness, no need to include manually unless the user asks.
35 - **Do not touch** the pre-existing modifications to `src/server/http/repo/issues.rs` and `src/server/http/repo/patches.rs`. They are unrelated to this work.
36 - **Working branch**: assume `main` (the user has been committing directly). If you want a worktree, create one before Task 1.
37
38 ---
39
40 ## Task 1: Add dependencies
41
42 **Files:**
43 - Modify: `Cargo.toml`
44
45 - [ ] **Step 1: Add pulldown-cmark and ammonia to `[dependencies]`**
46
47 In `Cargo.toml`, add these two lines to the `[dependencies]` block (alphabetical order is not enforced in the existing file; group near `askama` for tidiness):
48
49 ```toml
50 pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
51 ammonia = "4"
52 ```
53
54 - [ ] **Step 2: Verify the workspace builds**
55
56 Run: `cargo build`
57 Expected: clean build, two new crates resolved. If `pulldown-cmark` rejects `default-features = false` because `html` is not a feature in 0.12, fall back to `pulldown-cmark = "0.12"` and re-run `cargo build`.
58
59 - [ ] **Step 3: Commit**
60
61 ```bash
62 git add Cargo.toml Cargo.lock
63 git commit -m "deps: add pulldown-cmark and ammonia for README rendering"
64 ```
65
66 ---
67
68 ## Task 2: Module skeleton + first failing test (no README → None)
69
70 **Files:**
71 - Create: `src/server/http/repo/readme.rs`
72 - Modify: `src/server/http/repo/mod.rs`
73
74 - [ ] **Step 1: Create the module file with type and stub**
75
76 Create `src/server/http/repo/readme.rs`:
77
78 ```rust
79 //! README lookup, decoding, and rendering for the repo overview page.
80 //!
81 //! Pure: no Axum / AppState dependencies, so this is unit-testable against
82 //! fixture repos built with `git2::Repository::init`.
83
84 use git2::Repository;
85
86 /// Already-safe HTML ready to be rendered with `|safe` in the template.
87 pub struct RenderedReadme {
88 pub html: String,
89 }
90
91 /// Load and render the README at the root of HEAD's tree.
92 ///
93 /// Returns `None` when no README is present, the blob is binary or invalid
94 /// UTF-8, or any unexpected git2 error occurs. The README must never break
95 /// the overview page — failures degrade silently to "no README".
96 pub fn load_readme(_repo: &Repository) -> Option<RenderedReadme> {
97 todo!("Task 2 implements the no-README path")
98 }
99
100 #[cfg(test)]
101 mod tests {
102 use super::*;
103 use tempfile::TempDir;
104
105 /// Build an empty git repo with a single commit containing the given
106 /// (path, contents) blobs at the root tree. Returns the repo and the
107 /// tempdir (kept alive by the caller).
108 fn repo_with_files(files: &[(&str, &[u8])]) -> (Repository, TempDir) {
109 let tmp = TempDir::new().unwrap();
110 let repo = Repository::init(tmp.path()).unwrap();
111
112 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
113 let mut builder = repo.treebuilder(None).unwrap();
114 for (name, contents) in files {
115 let oid = repo.blob(contents).unwrap();
116 builder.insert(name, oid, 0o100644).unwrap();
117 }
118 let tree_oid = builder.write().unwrap();
119 let tree = repo.find_tree(tree_oid).unwrap();
120 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
121
122 (repo, tmp)
123 }
124
125 #[test]
126 fn no_readme_returns_none() {
127 let (repo, _tmp) = repo_with_files(&[("src/lib.rs", b"fn main() {}")]);
128 assert!(load_readme(&repo).is_none());
129 }
130 }
131 ```
132
133 - [ ] **Step 2: Wire the module into `repo/mod.rs`**
134
135 In `src/server/http/repo/mod.rs`, add `mod readme;` next to the other `mod` lines (around line 6, after `mod issues;`). Do NOT add a `pub use` yet — `overview.rs` will reach in via `super::readme` in Task 9.
136
137 - [ ] **Step 3: Run the test, see it fail**
138
139 Run: `cargo test -p git-collab readme::tests::no_readme_returns_none`
140 Expected: panic with `not yet implemented: Task 2 implements the no-README path`.
141
142 - [ ] **Step 4: Implement the minimal no-README path**
143
144 Replace the `todo!` body in `load_readme` with:
145
146 ```rust
147 pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
148 let head = repo.head().ok()?;
149 let commit = head.peel_to_commit().ok()?;
150 let tree = commit.tree().ok()?;
151
152 let _entry = find_readme_entry(&tree)?;
153 None // Task 3 will replace this
154 }
155
156 /// One root-tree entry. Carries the resolved category so the renderer can
157 /// branch on type without re-parsing the filename.
158 #[derive(Debug)]
159 struct ReadmeEntry {
160 name: String,
161 oid: git2::Oid,
162 kind: ReadmeKind,
163 }
164
165 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
166 enum ReadmeKind {
167 Markdown,
168 Plain,
169 }
170
171 /// Walk the root tree (non-recursive) and pick the highest-precedence README
172 /// blob entry. Symlinks and tree entries are ignored.
173 fn find_readme_entry(_tree: &git2::Tree) -> Option<ReadmeEntry> {
174 None // Task 3 implements the real walk
175 }
176 ```
177
178 - [ ] **Step 5: Run the test again, see it pass**
179
180 Run: `cargo test -p git-collab readme::tests::no_readme_returns_none`
181 Expected: 1 passed.
182
183 - [ ] **Step 6: Commit**
184
185 ```bash
186 git add src/server/http/repo/readme.rs src/server/http/repo/mod.rs
187 git commit -m "feat(server): scaffold readme module + no-readme test"
188 ```
189
190 ---
191
192 ## Task 3: Lookup precedence (the seven lookup tests)
193
194 **Files:**
195 - Modify: `src/server/http/repo/readme.rs`
196
197 - [ ] **Step 1: Add the failing tests for lookup**
198
199 Append to the `tests` module in `src/server/http/repo/readme.rs`:
200
201 ```rust
202 #[test]
203 fn finds_uppercase_readme_md() {
204 let (repo, _tmp) = repo_with_files(&[("README.md", b"# Title\n\nbody\n")]);
205 let r = load_readme(&repo).expect("README found");
206 assert!(r.html.contains("<h1>Title</h1>"), "got: {}", r.html);
207 }
208
209 #[test]
210 fn finds_mixed_case_readme_md() {
211 let (repo, _tmp) = repo_with_files(&[("Readme.MD", b"# T\n")]);
212 assert!(load_readme(&repo).is_some());
213 }
214
215 #[test]
216 fn md_wins_over_txt() {
217 let (repo, _tmp) = repo_with_files(&[
218 ("README.md", b"# md\n"),
219 ("README.txt", b"plain"),
220 ]);
221 let r = load_readme(&repo).unwrap();
222 assert!(r.html.contains("<h1>md</h1>"));
223 assert!(!r.html.contains("plain"));
224 }
225
226 #[test]
227 fn readme_wins_over_txt() {
228 let (repo, _tmp) = repo_with_files(&[
229 ("README", b"plain readme"),
230 ("README.txt", b"plain txt"),
231 ]);
232 let r = load_readme(&repo).unwrap();
233 assert!(r.html.contains("plain readme"));
234 assert!(!r.html.contains("plain txt"));
235 }
236
237 #[test]
238 fn mixed_case_md_still_wins_over_lowercase_txt() {
239 let (repo, _tmp) = repo_with_files(&[
240 ("README.md", b"# md\n"),
241 ("readme.txt", b"plain"),
242 ]);
243 assert!(load_readme(&repo).unwrap().html.contains("<h1>md</h1>"));
244 }
245
246 #[test]
247 fn nested_readme_is_not_matched() {
248 // Note: tree is built non-recursively here, so we put the nested README
249 // in a subtree manually.
250 let tmp = TempDir::new().unwrap();
251 let repo = Repository::init(tmp.path()).unwrap();
252 let sig = git2::Signature::now("T", "t@e").unwrap();
253
254 let blob = repo.blob(b"# nope\n").unwrap();
255 let mut sub = repo.treebuilder(None).unwrap();
256 sub.insert("README.md", blob, 0o100644).unwrap();
257 let sub_oid = sub.write().unwrap();
258
259 let mut root = repo.treebuilder(None).unwrap();
260 root.insert("docs", sub_oid, 0o040000).unwrap();
261 let root_oid = root.write().unwrap();
262 let tree = repo.find_tree(root_oid).unwrap();
263 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
264
265 assert!(load_readme(&repo).is_none());
266 }
267
268 #[test]
269 fn symlink_readme_is_ignored() {
270 let tmp = TempDir::new().unwrap();
271 let repo = Repository::init(tmp.path()).unwrap();
272 let sig = git2::Signature::now("T", "t@e").unwrap();
273
274 // Symlinks are stored as a blob whose contents are the target path,
275 // with file mode 0o120000.
276 let target_blob = repo.blob(b"docs/REAL.md").unwrap();
277 let mut root = repo.treebuilder(None).unwrap();
278 root.insert("README.md", target_blob, 0o120000).unwrap();
279 let root_oid = root.write().unwrap();
280 let tree = repo.find_tree(root_oid).unwrap();
281 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
282
283 assert!(load_readme(&repo).is_none());
284 }
285 ```
286
287 - [ ] **Step 2: Run them, see them fail**
288
289 Run: `cargo test -p git-collab readme::tests`
290 Expected: the new tests fail (most assert `Some(...)` against the current stub which returns `None`); `no_readme_returns_none` still passes.
291
292 - [ ] **Step 3: Implement the lookup**
293
294 Replace `find_readme_entry` in `readme.rs`:
295
296 ```rust
297 fn find_readme_entry(tree: &git2::Tree) -> Option<ReadmeEntry> {
298 // Score: lower is better. None means "not a README".
299 fn classify(name: &str) -> Option<(u8, ReadmeKind)> {
300 match name.to_ascii_lowercase().as_str() {
301 "readme.md" => Some((0, ReadmeKind::Markdown)),
302 "readme" => Some((1, ReadmeKind::Plain)),
303 "readme.txt" => Some((2, ReadmeKind::Plain)),
304 _ => None,
305 }
306 }
307
308 let mut best: Option<(u8, ReadmeEntry)> = None;
309 for entry in tree.iter() {
310 // Skip subtrees, symlinks, submodules — only regular blobs.
311 if entry.kind() != Some(git2::ObjectType::Blob) {
312 continue;
313 }
314 if entry.filemode() != 0o100644 && entry.filemode() != 0o100755 {
315 continue; // 0o120000 (symlink) and anything else
316 }
317 let name = match entry.name() {
318 Some(n) => n,
319 None => continue,
320 };
321 let (score, kind) = match classify(name) {
322 Some(v) => v,
323 None => continue,
324 };
325 let candidate = ReadmeEntry { name: name.to_string(), oid: entry.id(), kind };
326 match &best {
327 None => best = Some((score, candidate)),
328 Some((cur_score, _)) if score < *cur_score => best = Some((score, candidate)),
329 _ => {}
330 }
331 }
332 best.map(|(_, e)| e)
333 }
334 ```
335
336 And update `load_readme` to actually render markdown blobs (placeholder plain rendering will come in Task 4 — for now make markdown work end-to-end so the lookup tests can verify content):
337
338 ```rust
339 pub fn load_readme(repo: &Repository) -> Option<RenderedReadme> {
340 let head = repo.head().ok()?;
341 let commit = head.peel_to_commit().ok()?;
342 let tree = commit.tree().ok()?;
343 let entry = find_readme_entry(&tree)?;
344
345 let blob = repo.find_blob(entry.oid).ok()?;
346 if blob.is_binary() {
347 return None;
348 }
349 let text = std::str::from_utf8(blob.content()).ok()?;
350
351 let html = match entry.kind {
352 ReadmeKind::Markdown => render_markdown(text),
353 ReadmeKind::Plain => render_plain(text),
354 };
355 Some(RenderedReadme { html })
356 }
357
358 fn render_markdown(src: &str) -> String {
359 use pulldown_cmark::{Options, Parser, html};
360 let mut opts = Options::empty();
361 opts.insert(Options::ENABLE_TABLES);
362 opts.insert(Options::ENABLE_STRIKETHROUGH);
363 opts.insert(Options::ENABLE_TASKLISTS);
364 let parser = Parser::new_ext(src, opts);
365 let mut unsafe_html = String::new();
366 html::push_html(&mut unsafe_html, parser);
367 sanitize(&unsafe_html)
368 }
369
370 fn render_plain(src: &str) -> String {
371 // Task 4 will tighten this. For now, escape and wrap.
372 let escaped = html_escape(src);
373 format!("<pre>{}</pre>", escaped)
374 }
375
376 fn html_escape(s: &str) -> String {
377 s.replace('&', "&amp;")
378 .replace('<', "&lt;")
379 .replace('>', "&gt;")
380 .replace('"', "&quot;")
381 .replace('\'', "&#39;")
382 }
383
384 fn sanitize(html: &str) -> String {
385 // Task 5 will tighten the policy (img URL schemes). Default is fine for now.
386 ammonia::clean(html)
387 }
388 ```
389
390 - [ ] **Step 4: Run the test module, see all 7 lookup tests pass**
391
392 Run: `cargo test -p git-collab readme::tests`
393 Expected: 8 passed (the 7 new + the original `no_readme_returns_none`).
394
395 - [ ] **Step 5: Commit**
396
397 ```bash
398 git add src/server/http/repo/readme.rs
399 git commit -m "feat(server): readme lookup with precedence and markdown rendering"
400 ```
401
402 ---
403
404 ## Task 4: Plain-text rendering test
405
406 **Files:**
407 - Modify: `src/server/http/repo/readme.rs`
408
409 The plain-text path is already wired through `render_plain`. This task adds a test that exercises the escape behavior on a `<script>`-bearing plain README and locks it in.
410
411 - [ ] **Step 1: Add the test**
412
413 Append to the `tests` module:
414
415 ```rust
416 #[test]
417 fn plain_readme_escapes_script_tag() {
418 let (repo, _tmp) = repo_with_files(&[
419 ("README", b"<script>alert(1)</script>\nhello"),
420 ]);
421 let r = load_readme(&repo).unwrap();
422 assert!(r.html.starts_with("<pre>"));
423 assert!(r.html.contains("&lt;script&gt;"));
424 assert!(!r.html.contains("<script>"));
425 assert!(r.html.contains("hello"));
426 }
427 ```
428
429 - [ ] **Step 2: Run it**
430
431 Run: `cargo test -p git-collab readme::tests::plain_readme_escapes_script_tag`
432 Expected: PASS (already implemented by Task 3).
433
434 - [ ] **Step 3: Commit (if anything changed)**
435
436 Nothing to commit unless the test required code changes. Skip the commit step if there's no diff.
437
438 ---
439
440 ## Task 5: Sanitization (script, javascript:, onerror, iframe, data: img)
441
442 **Files:**
443 - Modify: `src/server/http/repo/readme.rs`
444
445 - [ ] **Step 1: Add the failing sanitizer tests**
446
447 Append to the `tests` module:
448
449 ```rust
450 #[test]
451 fn markdown_strips_script_tag() {
452 let (repo, _tmp) = repo_with_files(&[
453 ("README.md", b"# t\n\n<script>alert(1)</script>\n"),
454 ]);
455 let html = load_readme(&repo).unwrap().html;
456 assert!(!html.contains("<script>"), "got: {}", html);
457 }
458
459 #[test]
460 fn markdown_strips_javascript_href() {
461 let (repo, _tmp) = repo_with_files(&[
462 ("README.md", b"[click](javascript:alert(1))\n"),
463 ]);
464 let html = load_readme(&repo).unwrap().html;
465 assert!(!html.contains("javascript:"), "got: {}", html);
466 }
467
468 #[test]
469 fn markdown_strips_onerror_attribute() {
470 let (repo, _tmp) = repo_with_files(&[
471 ("README.md", b"<img src=\"https://x/y.png\" onerror=\"alert(1)\">\n"),
472 ]);
473 let html = load_readme(&repo).unwrap().html;
474 assert!(!html.contains("onerror"), "got: {}", html);
475 }
476
477 #[test]
478 fn markdown_strips_iframe() {
479 let (repo, _tmp) = repo_with_files(&[
480 ("README.md", b"<iframe src=\"https://evil.example/\"></iframe>\n"),
481 ]);
482 let html = load_readme(&repo).unwrap().html;
483 assert!(!html.contains("<iframe"), "got: {}", html);
484 }
485
486 #[test]
487 fn markdown_strips_data_image_uri() {
488 let (repo, _tmp) = repo_with_files(&[
489 ("README.md", b"<img src=\"data:image/png;base64,AAAA\">\n"),
490 ]);
491 let html = load_readme(&repo).unwrap().html;
492 // Either the whole <img> is dropped or the src attr is gone.
493 assert!(!html.contains("data:"), "got: {}", html);
494 }
495 ```
496
497 - [ ] **Step 2: Run them**
498
499 Run: `cargo test -p git-collab readme::tests`
500 Expected: most pass with default ammonia (script, javascript:, onerror, iframe). The `data:` test **fails** because ammonia's default `<img>` URL scheme allowlist includes `data`.
501
502 - [ ] **Step 3: Tighten the sanitizer to drop `data:` from `<img src>`**
503
504 Replace the `sanitize` function in `readme.rs` with a `Builder`-based one:
505
506 ```rust
507 fn sanitize(html: &str) -> String {
508 use std::collections::HashSet;
509
510 // Restrict <img src> URL schemes to http/https only — no data:, no javascript:.
511 // ammonia::Builder::url_schemes() applies the allowlist to ALL URL-bearing
512 // attributes, which is what we want.
513 let mut schemes: HashSet<&str> = HashSet::new();
514 schemes.insert("http");
515 schemes.insert("https");
516 schemes.insert("mailto");
517
518 ammonia::Builder::default()
519 .url_schemes(schemes)
520 .clean(html)
521 .to_string()
522 }
523 ```
524
525 - [ ] **Step 4: Re-run the tests**
526
527 Run: `cargo test -p git-collab readme::tests`
528 Expected: all sanitizer tests pass. Lookup tests still pass.
529
530 - [ ] **Step 5: Commit**
531
532 ```bash
533 git add src/server/http/repo/readme.rs
534 git commit -m "feat(server): tighten readme sanitizer img URL schemes"
535 ```
536
537 ---
538
539 ## Task 6: Size cap, UTF-8 strict, post-render bomb cap
540
541 **Files:**
542 - Modify: `src/server/http/repo/readme.rs`
543
544 - [ ] **Step 1: Widen `load_readme` signature to take repo name + branch**
545
546 The "too large" notice needs to render a link of the form
547 `/{repo_name}/blob/{branch}/{file_name}`. `load_readme` is pure (no AppState), so
548 it must receive both `repo_name` and `branch` as arguments. Change the public
549 signature in `readme.rs`:
550
551 ```rust
552 pub fn load_readme(
553 repo: &Repository,
554 repo_name: &str,
555 branch: &str,
556 ) -> Option<RenderedReadme> { /* existing body unchanged for now */ }
557 ```
558
559 Add a small test helper at the top of the `tests` module so the existing tests
560 do not have to know about the new arguments:
561
562 ```rust
563 fn load(repo: &Repository) -> Option<RenderedReadme> {
564 let branch = repo.head().ok()
565 .and_then(|h| h.shorthand().map(String::from))
566 .unwrap_or_else(|| "main".to_string());
567 load_readme(repo, "test-repo", &branch)
568 }
569 ```
570
571 …then replace every existing `load_readme(&repo)` call in the tests with
572 `load(&repo)`. Run `cargo test -p git-collab readme::tests` to confirm
573 nothing regressed.
574
575 - [ ] **Step 2: Add the size constants**
576
577 Near the top of `readme.rs`, after the type definitions, add:
578
579 ```rust
580 const MAX_BLOB_BYTES: usize = 512 * 1024;
581 const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
582 ```
583
584 - [ ] **Step 3: Add the size / UTF-8 / bomb tests**
585
586 Append to the `tests` module:
587
588 ```rust
589 #[test]
590 fn empty_readme_renders_empty_body() {
591 let (repo, _tmp) = repo_with_files(&[("README.md", b"")]);
592 let r = load(&repo).expect("present-but-empty is Some");
593 // ammonia of empty markdown is the empty string; assert it's not the
594 // too-large notice and not None.
595 assert!(!r.html.contains("too large"));
596 }
597
598 #[test]
599 fn oversized_blob_returns_too_large_notice() {
600 let big = vec![b'x'; 600 * 1024];
601 let (repo, _tmp) = repo_with_files(&[("README.md", &big)]);
602 let r = load(&repo).unwrap();
603 assert!(r.html.contains("too large"));
604 assert!(r.html.contains("/blob/"));
605 assert!(r.html.contains("README.md"));
606 }
607
608 #[test]
609 fn markdown_bomb_post_render_cap_trips() {
610 // A long table row replicated many times: small source, huge HTML.
611 let mut src = String::from("| a | b |\n|---|---|\n");
612 for _ in 0..200_000 {
613 src.push_str("| xxxxxxxxxxxx | yyyyyyyyyyyy |\n");
614 }
615 // Source is well under 512 KiB but rendered HTML will exceed 2 MiB.
616 assert!(src.len() < MAX_BLOB_BYTES);
617 let (repo, _tmp) = repo_with_files(&[("README.md", src.as_bytes())]);
618 let r = load(&repo).unwrap();
619 assert!(r.html.contains("too large"), "expected bomb to trip cap");
620 }
621
622 #[test]
623 fn binary_blob_returns_none() {
624 let (repo, _tmp) = repo_with_files(&[("README.md", &[0u8, 1, 2, 3, 0xff, 0xfe])]);
625 assert!(load(&repo).is_none());
626 }
627
628 #[test]
629 fn invalid_utf8_returns_none() {
630 // Mostly valid text + a stray 0x80 byte. Not flagged as binary by git2's
631 // heuristic (no NULs), but not valid UTF-8 either.
632 let mut bytes = Vec::from(&b"hello world\nmore text\n"[..]);
633 bytes.push(0x80);
634 bytes.extend_from_slice(b"\nmore\n");
635 let (repo, _tmp) = repo_with_files(&[("README.md", &bytes)]);
636 assert!(load(&repo).is_none());
637 }
638 ```
639
640 - [ ] **Step 4: Run them, see them fail**
641
642 Run: `cargo test -p git-collab readme::tests`
643 Expected: oversized + bomb tests fail (current code doesn't cap). The other new tests may pass already.
644
645 - [ ] **Step 5: Implement the caps and the notice helper**
646
647 Add the notice helper and replace the body of `load_readme` with the full version:
648
649 ```rust
650 pub fn load_readme(
651 repo: &Repository,
652 repo_name: &str,
653 branch: &str,
654 ) -> Option<RenderedReadme> {
655 let head = repo.head().ok()?;
656 let commit = head.peel_to_commit().ok()?;
657 let tree = commit.tree().ok()?;
658 let entry = find_readme_entry(&tree)?;
659
660 let blob = match repo.find_blob(entry.oid) {
661 Ok(b) => b,
662 Err(err) => {
663 tracing::warn!(repo = repo_name, error = %err, "failed to load readme blob");
664 return None;
665 }
666 };
667
668 if blob.is_binary() {
669 return None;
670 }
671
672 if blob.size() > MAX_BLOB_BYTES {
673 return Some(RenderedReadme {
674 html: too_large_notice_with_repo(repo_name, branch, &entry.name),
675 });
676 }
677
678 let text = std::str::from_utf8(blob.content()).ok()?;
679
680 let html = match entry.kind {
681 ReadmeKind::Markdown => {
682 let rendered = render_markdown(text);
683 if rendered.len() > MAX_HTML_BYTES {
684 too_large_notice_with_repo(repo_name, branch, &entry.name)
685 } else {
686 rendered
687 }
688 }
689 ReadmeKind::Plain => render_plain(text),
690 };
691 Some(RenderedReadme { html })
692 }
693
694 fn too_large_notice_with_repo(repo_name: &str, branch: &str, file_name: &str) -> String {
695 format!(
696 "<p><em>README too large to render. \
697 <a href=\"/{repo}/blob/{branch}/{name}\">View raw</a>.</em></p>",
698 repo = html_escape(repo_name),
699 branch = html_escape(branch),
700 name = html_escape(file_name),
701 )
702 }
703 ```
704
705 - [ ] **Step 6: Re-run all readme tests**
706
707 Run: `cargo test -p git-collab readme::tests`
708 Expected: all tests pass. If the bomb test does not actually exceed 2 MiB of HTML, increase the loop count until it does.
709
710 - [ ] **Step 7: Commit**
711
712 ```bash
713 git add src/server/http/repo/readme.rs
714 git commit -m "feat(server): readme size caps, UTF-8 strict, blob link"
715 ```
716
717 ---
718
719 ## Task 7: Wire `load_readme` into the overview handler
720
721 **Files:**
722 - Modify: `src/server/http/repo/overview.rs`
723 - Modify: `src/server/http/repo/mod.rs`
724
725 - [ ] **Step 1: Re-export the type if needed**
726
727 In `src/server/http/repo/mod.rs`, the existing `mod readme;` is enough. Inside `overview.rs` we'll refer to it via `super::readme`.
728
729 - [ ] **Step 2: Update `OverviewTemplate` and the handler**
730
731 In `src/server/http/repo/overview.rs`, update the imports and template struct:
732
733 ```rust
734 use super::{
735 AppState, OverviewCommit, collab_counts, head_branch_name, open_repo, recent_commits,
736 readme::{self, RenderedReadme},
737 };
738 ```
739
740 Add the field to `OverviewTemplate`:
741
742 ```rust
743 #[derive(askama::Template, askama_web::WebTemplate)]
744 #[template(path = "repo_overview.html")]
745 pub struct OverviewTemplate {
746 pub site_title: String,
747 pub repo_name: String,
748 pub active_section: String,
749 pub open_patches: usize,
750 pub open_issues: usize,
751 pub readme: Option<RenderedReadme>,
752 pub commits: Vec<OverviewCommit>,
753 pub patches: Vec<OverviewPatch>,
754 pub issues: Vec<OverviewIssue>,
755 }
756 ```
757
758 In the `overview` async function, after `let commits = recent_commits(&repo, 10);`, add:
759
760 ```rust
761 let branch = head_branch_name(&repo);
762 let readme = readme::load_readme(&repo, &repo_name, &branch);
763 ```
764
765 …and include `readme` in the struct literal.
766
767 - [ ] **Step 3: Verify it compiles**
768
769 Run: `cargo build`
770 Expected: clean build. (Template still references the old fields — adding a new optional one won't break it.)
771
772 - [ ] **Step 4: Commit**
773
774 ```bash
775 git add src/server/http/repo/overview.rs src/server/http/repo/mod.rs
776 git commit -m "feat(server): plumb readme into overview handler"
777 ```
778
779 ---
780
781 ## Task 8: Render the README in the overview template
782
783 **Files:**
784 - Modify: `src/server/http/templates/repo_overview.html`
785
786 - [ ] **Step 1: Add the readme card above the patches/issues grid**
787
788 Insert this block at the top of the `{% block content %}` section, **before** the existing `<div style="display: grid; ...">`:
789
790 ```html
791 {% if let Some(r) = readme %}
792 <div style="border: 1px solid #ccc; padding: 16px; margin-bottom: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.5;">
793 <h3 style="margin-top: 0; font-family: monospace;">README</h3>
794 <div class="readme-body">{{ r.html|safe }}</div>
795 </div>
796 {% endif %}
797 ```
798
799 The `font-family: ... sans-serif` override is intentional: the rest of the site uses monospace, but rendered prose looks ugly in monospace. The `<h3>` keeps the monospace face to match the section headings on the rest of the page.
800
801 - [ ] **Step 2: Build and visually verify**
802
803 Run: `cargo build`
804 Expected: clean build (askama compiles the template at build time).
805
806 Then start the server against a test repo (or use whatever the project's standard "run a dev server" command is — check `README.md` or `Cargo.toml` `[[bin]]` entries) and load `http://localhost:<port>/<repo>` for a repo that has a `README.md`. Confirm:
807 - README appears above patches/issues
808 - Markdown is rendered (heading is bold, lists render, etc.)
809 - A repo with no README still renders the page exactly as before
810
811 If you cannot run the dev server in the current environment, **say so explicitly** and rely on the integration test in Task 9 instead of claiming visual success.
812
813 - [ ] **Step 3: Commit**
814
815 ```bash
816 git add src/server/http/templates/repo_overview.html
817 git commit -m "feat(server): render readme card on repo overview"
818 ```
819
820 ---
821
822 ## Task 9: Integration test via ServerHarness
823
824 **Files:**
825 - Modify: `tests/server_behavior_test.rs`
826
827 - [ ] **Step 1: Add the failing test**
828
829 Append a new `#[test]` function to `tests/server_behavior_test.rs`:
830
831 ```rust
832 #[test]
833 fn readme_md_renders_on_repo_overview_page() {
834 let harness = ServerHarness::new("behavior-readme");
835
836 harness.work_repo().commit_file(
837 "README.md",
838 "# Hello World\n\nA short description with a [link](https://example.com).\n",
839 "add README",
840 );
841 harness.push_head();
842
843 let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
844 assert!(overview.body.contains("<h1>Hello World</h1>"), "missing h1: {}", overview.body);
845 assert!(overview.body.contains("href=\"https://example.com\""));
846 // Sanity: the new card wrapper exists.
847 assert!(overview.body.contains("class=\"readme-body\""));
848 }
849
850 #[test]
851 fn missing_readme_does_not_break_overview_page() {
852 let harness = ServerHarness::new("behavior-no-readme");
853
854 harness.work_repo().commit_file(
855 "src/lib.rs",
856 "pub fn x() {}\n",
857 "add lib",
858 );
859 harness.push_head();
860
861 let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
862 assert!(!overview.body.contains("class=\"readme-body\""));
863 // The rest of the page still renders.
864 assert!(overview.body.contains("Open Patches") || overview.body.contains("Recent Commits"));
865 }
866 ```
867
868 - [ ] **Step 2: Run the integration tests**
869
870 Run: `cargo test --test server_behavior_test readme`
871 Expected: both tests pass. (The handler change in Task 7 + template change in Task 8 are now exercised end-to-end.)
872
873 - [ ] **Step 3: Run the full suite to make sure nothing else regressed**
874
875 Run: `cargo test`
876 Expected: all tests pass. If the pre-existing modifications to `issues.rs` / `patches.rs` cause failures, that's unrelated to this work — leave them alone and surface the failures to the user rather than fixing them.
877
878 - [ ] **Step 4: Commit**
879
880 ```bash
881 git add tests/server_behavior_test.rs
882 git commit -m "test(server): integration tests for readme rendering on overview"
883 ```
884
885 ---
886
887 ## Task 10: Lint pass
888
889 **Files:** none (or whatever clippy flags)
890
891 - [ ] **Step 1: Run clippy**
892
893 Run: `cargo clippy --all-targets -- -D warnings`
894 Expected: clean. If clippy flags anything in `readme.rs` or the handler, fix it inline (do not silence with `#[allow]` unless the lint is genuinely wrong for the situation).
895
896 - [ ] **Step 2: Commit any clippy fixes**
897
898 ```bash
899 git add -u
900 git commit -m "chore(server): clippy fixes for readme module"
901 ```
902
903 Skip if there are no fixes.
904
905 ---
906
907 ## Acceptance gate (run before declaring done)
908
909 - [ ] `cargo test -p git-collab readme::` — all unit tests pass
910 - [ ] `cargo test --test server_behavior_test readme` — both integration tests pass
911 - [ ] `cargo test` — full suite green (modulo pre-existing unrelated failures in `issues.rs` / `patches.rs`)
912 - [ ] `cargo clippy --all-targets -- -D warnings` — clean
913 - [ ] Manual visit (if a dev server is available): repo with `README.md` shows rendered README; repo without one is unchanged
914 - [ ] Spec checklist:
915 - [ ] Lookup precedence and case-insensitivity ✅ Tasks 3
916 - [ ] Markdown via pulldown-cmark + ammonia with tightened img schemes ✅ Tasks 3, 5
917 - [ ] Plain-text fallback ✅ Tasks 3, 4
918 - [ ] 512 KiB blob cap + 2 MiB post-render cap ✅ Task 6
919 - [ ] Strict UTF-8 decode ✅ Task 6
920 - [ ] Symlink and binary entries ignored ✅ Tasks 3, 6
921 - [ ] Blob link uses resolved branch name, not literal "HEAD" ✅ Tasks 6, 7
922 - [ ] Bordered card with `<h3>README</h3>` ✅ Task 8
923 - [ ] Warn-once on unexpected git2 errors ✅ Task 6
924 - [ ] All 19 spec test cases mapped to actual tests ✅ Tasks 2, 3, 4, 5, 6
docs/superpowers/specs/2026-04-12-render-readme-on-overview-design.md
Old New
@@ -0,0 +1,223 @@
1 # Render README on Repo Overview
2
3 **Date:** 2026-04-12
4 **Status:** Approved
5
6 ## Goal
7
8 On the repo overview page (`GET /{repo}`), render the repository's README from
9 HEAD above the existing patches / issues / recent-commits sections, so visitors
10 landing on a repo see its description without having to browse the file tree.
11
12 ## Scope
13
14 In scope:
15
16 - Lookup of a README file at the root of HEAD's tree.
17 - Markdown rendering for `README.md` (case-insensitive).
18 - Plain-text fallback for `README` and `README.txt`.
19 - HTML sanitization of rendered markdown.
20 - Size cap with a "too large" notice.
21 - Graceful no-op when no README exists or anything fails.
22
23 Out of scope (deferred, easy to add later):
24
25 - README rendering on the tree-root page.
26 - Relative-link rewriting (e.g. `./screenshot.png`, links to sibling files).
27 - Syntax highlighting in fenced code blocks.
28 - `.rst`, `.org`, AsciiDoc.
29 - Per-branch README selection on the overview page.
30
31 ## File Lookup
32
33 Walk the root tree of `HEAD`'s commit once. Match the first entry whose
34 lowercased name matches one of, in order:
35
36 1. `readme.md`
37 2. `readme`
38 3. `readme.txt`
39
40 If no match, the overview page renders exactly as it does today — no README
41 block, no error, no log noise. A missing README is the common case.
42
43 The lookup is HEAD-only. Branch / ref selection is out of scope.
44
45 ## Rendering
46
47 - `.md` match → `pulldown-cmark` (with tables, strikethrough, and task-list
48 extensions enabled) → HTML string → `ammonia` with a tightened policy:
49 - Start from `ammonia::Builder::default()`.
50 - **Restrict `<img>` URL schemes to `http` and `https` only** (no `data:`,
51 no `javascript:`). Done via `Builder::url_schemes` plus targeted
52 `tag_attribute_values` if needed so the policy applies to image sources.
53 - Defaults already strip `<script>`, `<style>`, `<iframe>`, inline event
54 handlers, and `javascript:` URLs in `<a href>`.
55 - `readme` / `.txt` match → HTML-escape the contents and wrap in `<pre>`.
56 - Binary blob (per `git2::Blob::is_binary`) → treat as missing, return `None`.
57 - Decode blob bytes with **strict `std::str::from_utf8`**. Invalid UTF-8 →
58 treat as missing, return `None`. (No lossy decoding — keeps invariants
59 simple and avoids rendering replacement-character soup.)
60 - **Uncompressed blob size** (`git2::Blob::size`) > **512 KiB** → return a
61 fixed "README too large to render" notice containing a link to the
62 corresponding blob view. The blob link uses the resolved default branch
63 name from `head_branch_name(&repo)`, not the literal string `HEAD`.
64 - **Post-render HTML cap**: after `pulldown-cmark` produces HTML, if the
65 output exceeds **2 MiB**, discard it and return the same "too large"
66 notice. Guards against markdown bombs (deeply nested lists, large tables,
67 reference-link expansion) where a small source produces enormous HTML.
68
69 The rendered output is **already-safe HTML** by the time it leaves the
70 renderer, so the template can mark it `|safe` without further escaping.
71
72 ## Components
73
74 ### `src/server/http/repo/readme.rs` (new)
75
76 A pure module with no Axum / state dependencies, so it can be unit-tested
77 against fixture repos.
78
79 ```rust
80 pub struct RenderedReadme {
81 pub html: String,
82 }
83
84 pub fn load_readme(repo: &git2::Repository) -> Option<RenderedReadme>;
85 ```
86
87 `load_readme` performs the tree walk, classification (markdown vs plain vs
88 binary vs oversized), rendering, and sanitization. Every `git2` error inside
89 this function maps to `None` — README rendering must never break the overview
90 page.
91
92 ### `src/server/http/repo/overview.rs` (modified)
93
94 - Add `readme: Option<RenderedReadme>` to `OverviewTemplate`.
95 - Call `readme::load_readme(&repo)` after opening the repo and before building
96 the template.
97
98 ### `src/server/http/templates/repo_overview.html` (modified)
99
100 Add a single new top section, before the existing patches/issues grid,
101 wrapped in a bordered card with a small header so a README starting with its
102 own `<h1>` doesn't visually collide with the page chrome:
103
104 ```html
105 {% if let Some(r) = readme %}
106 <div class="card readme">
107 <h3 style="margin-top: 0;">README</h3>
108 <div class="readme-body">{{ r.html|safe }}</div>
109 </div>
110 {% endif %}
111 ```
112
113 Card styling matches the visual treatment of the existing patches/issues
114 panels. Minimal inline styles, no new CSS file.
115
116 ### `src/server/http/repo/mod.rs` (modified)
117
118 `pub mod readme;` and re-export `RenderedReadme` if needed by `overview.rs`.
119
120 ## Dependencies
121
122 Add to the root `Cargo.toml`:
123
124 - `pulldown-cmark = "0.12"` — implementer to confirm whether default
125 features are sufficient or `default-features = false` is preferred for
126 build-time hygiene; HTML output is in the default feature set.
127 - `ammonia = "4"`
128
129 Both are pure-Rust with no system dependencies.
130
131 ## Error Handling
132
133 - Missing README → `None` → template skips the block. **Silent** (common case).
134 - Invalid UTF-8 in the blob → `None`. Silent (treat as binary).
135 - Binary blob → `None`. Silent.
136 - Oversized blob (uncompressed > 512 KiB) → `Some(RenderedReadme { html: <fixed notice> })`.
137 - Oversized rendered HTML (post-render > 2 MiB) → same fixed notice.
138 - Markdown parser and ammonia sanitizer are both infallible by API.
139 - **Unexpected `git2` failure** after we've already resolved HEAD (e.g. tree
140 walk fails, or a blob OID present in the tree fails to load) → `None`,
141 but **log once at `warn`** with the repo name and the underlying error.
142 These should never happen on a healthy repo; silent swallowing here would
143 be a debugging tax.
144
145 The README block must never cause a 500 on the overview page. The rest of
146 the page renders fine without it.
147
148 ## Testing
149
150 ### Unit tests (in `readme.rs`)
151
152 Each test creates a tmp `git2::Repository::init`, writes a tree with the
153 required blobs, commits, and points HEAD at it.
154
155 **Lookup**
156
157 1. **No README** → `load_readme` returns `None`.
158 2. **`Readme.MD`** (mixed case) → found and rendered as markdown.
159 3. **Lookup precedence**: tree contains both `README.md` and `README.txt` →
160 markdown wins.
161 4. **Lookup precedence**: tree contains `README` and `README.txt` → `README`
162 wins (matches the documented order).
163 5. **Mixed-case precedence**: tree contains `README.md` (uppercase) and
164 `readme.txt` (lowercase) → markdown still wins.
165 6. **Nested README is NOT matched**: tree contains `docs/README.md` only
166 (no root README) → returns `None`. Guards against accidental recursive
167 walk.
168 7. **Symlink entry** (mode `0o120000`) named `README.md` → returned as `None`
169 (do not follow).
170
171 **Rendering & sanitization**
172
173 8. **`README.md` with `# Title\n\nbody`** → HTML contains `<h1>Title</h1>`.
174 9. **Plain `README` containing `<script>alert(1)</script>`** → result is a
175 `<pre>` block with `&lt;script&gt;`, no live tag.
176 10. **`README.md` containing raw `<script>alert(1)</script>`** → sanitizer
177 strips the tag; no `<script>` substring in output.
178 11. **`README.md` containing `<a href="javascript:alert(1)">x</a>`** →
179 rendered link has no `javascript:` href (ammonia drops the attr or tag).
180 12. **`README.md` containing `<img src=x onerror=alert(1)>`** → no `onerror`
181 attribute in the output.
182 13. **`README.md` containing `<iframe src="https://evil.example/">`** → no
183 `<iframe>` in the output.
184 14. **`README.md` containing `<img src="data:image/png;base64,AAAA">`** →
185 `data:` URL stripped (per the tightened `img` URL-scheme policy);
186 either the `src` attr is removed or the whole `<img>` tag is dropped.
187 This is the regression guard for the must-fix from review.
188
189 **Size & content edge cases**
190
191 15. **Empty `README.md`** (0 bytes) → returns `Some` with an empty (or near-
192 empty) rendered body. Pick one and assert it; do not return `None`.
193 16. **`README.md` of 600 KiB** → result is the "too large" notice with a
194 link to the blob view; markdown parser is NOT invoked.
195 17. **Markdown bomb**: small source (e.g. a deeply nested list or a long
196 reference-link expansion) whose rendered HTML exceeds the 2 MiB
197 post-render cap → result is the "too large" notice. Test must construct
198 a source that actually trips the cap, not just assert the cap value.
199 18. **Binary blob named `README.md`** (e.g. `\xff\xfe\x00...`) → `None`.
200 19. **Invalid UTF-8 (not flagged as binary by git2)**: blob containing valid
201 text plus a stray `\x80` byte → `None` (strict UTF-8 decode).
202
203 ### Integration test
204
205 If `src/server/http/` already has an HTTP test harness (Axum `Router` with
206 `tower::ServiceExt::oneshot` against a temp repo), add one test:
207
208 - Init a repo with `README.md`, request `GET /{repo}`, assert response body
209 contains the rendered README block (e.g. an `<h1>` from the source).
210
211 If no such harness exists yet, the unit tests are sufficient — leave the
212 integration test as a follow-up rather than scaffolding a harness for one test.
213
214 ## Acceptance
215
216 - Visiting `/{repo}` on a repo with a `README.md` shows rendered markdown
217 above patches/issues/commits.
218 - Visiting `/{repo}` on a repo with no README shows the page exactly as
219 before.
220 - A README containing `<script>` cannot execute JS in the browser.
221 - A 1 MiB README does not crash or hang the page; the user sees the
222 "too large" notice with a link to the blob view.
223 - `cargo test` and `cargo clippy` pass.
src/server/http/repo/mod.rs
Old New
@@ -3,6 +3,7 @@ mod diff;
3 mod issues; 3 mod issues;
4 mod overview; 4 mod overview;
5 mod patches; 5 mod patches;
6 mod readme;
6 mod releases; 7 mod releases;
7 mod tree; 8 mod tree;
8 9
src/server/http/repo/overview.rs
Old New
@@ -3,7 +3,10 @@ use std::sync::Arc;
3 use axum::extract::{Path, State}; 3 use axum::extract::{Path, State};
4 use axum::response::{IntoResponse, Response}; 4 use axum::response::{IntoResponse, Response};
5 5
6 use super::{collab_counts, open_repo, recent_commits, AppState, OverviewCommit}; 6 use super::{
7 AppState, OverviewCommit, collab_counts, head_branch_name, open_repo, recent_commits,
8 readme::{self, RenderedReadme},
9 };
7 10
8 #[derive(Debug)] 11 #[derive(Debug)]
9 pub struct OverviewPatch { 12 pub struct OverviewPatch {
@@ -27,6 +30,7 @@ pub struct OverviewTemplate {
27 pub active_section: String, 30 pub active_section: String,
28 pub open_patches: usize, 31 pub open_patches: usize,
29 pub open_issues: usize, 32 pub open_issues: usize,
33 pub readme: Option<RenderedReadme>,
30 pub commits: Vec<OverviewCommit>, 34 pub commits: Vec<OverviewCommit>,
31 pub patches: Vec<OverviewPatch>, 35 pub patches: Vec<OverviewPatch>,
32 pub issues: Vec<OverviewIssue>, 36 pub issues: Vec<OverviewIssue>,
@@ -43,6 +47,8 @@ pub async fn overview(
43 47
44 let commits = recent_commits(&repo, 10); 48 let commits = recent_commits(&repo, 10);
45 let (open_patches, open_issues) = collab_counts(&repo); 49 let (open_patches, open_issues) = collab_counts(&repo);
50 let branch = head_branch_name(&repo);
51 let readme = readme::load_readme(&repo, &repo_name, &branch);
46 52
47 let patches = git_collab::state::list_patches(&repo) 53 let patches = git_collab::state::list_patches(&repo)
48 .unwrap_or_default() 54 .unwrap_or_default()
@@ -72,6 +78,7 @@ pub async fn overview(
72 active_section: "overview".to_string(), 78 active_section: "overview".to_string(),
73 open_patches, 79 open_patches,
74 open_issues, 80 open_issues,
81 readme,
75 commits, 82 commits,
76 patches, 83 patches,
77 issues, 84 issues,
src/server/http/repo/readme.rs
Old New
@@ -0,0 +1,457 @@
1 //! README lookup, decoding, and rendering for the repo overview page.
2 //!
3 //! Pure: no Axum / AppState dependencies, so this is unit-testable against
4 //! fixture repos built with `git2::Repository::init`.
5
6 use git2::Repository;
7
8 /// Already-safe HTML ready to be rendered with `|safe` in the template.
9 pub struct RenderedReadme {
10 pub html: String,
11 }
12
13 const MAX_BLOB_BYTES: usize = 512 * 1024;
14 const MAX_HTML_BYTES: usize = 2 * 1024 * 1024;
15
16 /// Load and render the README at the root of HEAD's tree.
17 ///
18 /// Returns `None` when no README is present, the blob is binary or invalid
19 /// UTF-8, or any unexpected git2 error occurs. The README must never break
20 /// the overview page — failures degrade silently to "no README".
21 pub fn load_readme(
22 repo: &Repository,
23 repo_name: &str,
24 branch: &str,
25 ) -> Option<RenderedReadme> {
26 // Resolve the branch ref directly so this works on bare repos where HEAD
27 // may point to an unborn branch (e.g. `master` when only `main` is pushed).
28 let obj = repo
29 .revparse_single(&format!("refs/heads/{branch}"))
30 .or_else(|_| repo.revparse_single(branch))
31 .ok()?;
32 let commit = obj.peel_to_commit().ok()?;
33 let tree = commit.tree().ok()?;
34 let entry = find_readme_entry(&tree)?;
35
36 let blob = match repo.find_blob(entry.oid) {
37 Ok(b) => b,
38 Err(err) => {
39 tracing::warn!(repo = repo_name, error = %err, "failed to load readme blob");
40 return None;
41 }
42 };
43
44 if blob.is_binary() {
45 return None;
46 }
47
48 if blob.size() > MAX_BLOB_BYTES {
49 return Some(RenderedReadme {
50 html: too_large_notice_with_repo(repo_name, branch, &entry.name),
51 });
52 }
53
54 let text = std::str::from_utf8(blob.content()).ok()?;
55
56 let html = match entry.kind {
57 ReadmeKind::Markdown => {
58 let rendered = render_markdown(text);
59 if rendered.len() > MAX_HTML_BYTES {
60 too_large_notice_with_repo(repo_name, branch, &entry.name)
61 } else {
62 rendered
63 }
64 }
65 ReadmeKind::Plain => {
66 let rendered = render_plain(text);
67 if rendered.len() > MAX_HTML_BYTES {
68 too_large_notice_with_repo(repo_name, branch, &entry.name)
69 } else {
70 rendered
71 }
72 }
73 };
74 Some(RenderedReadme { html })
75 }
76
77 /// Build the "README too large" fallback HTML block. The link path is
78 /// assembled from `repo_name`, `branch`, and `file_name` after html-escaping.
79 /// We do NOT percent-encode: callers are expected to pass URL-safe values
80 /// (git branch and repo names that follow normal slug conventions, and one
81 /// of the fixed README filenames). Slashes in branch names are intentionally
82 /// preserved as path separators.
83 fn too_large_notice_with_repo(repo_name: &str, branch: &str, file_name: &str) -> String {
84 format!(
85 "<p><em>README too large to render. \
86 <a href=\"/{repo}/blob/{branch}/{name}\">View raw</a>.</em></p>",
87 repo = html_escape(repo_name),
88 branch = html_escape(branch),
89 name = html_escape(file_name),
90 )
91 }
92
93 /// One root-tree entry. Carries the resolved category so the renderer can
94 /// branch on type without re-parsing the filename.
95 #[derive(Debug)]
96 struct ReadmeEntry {
97 name: String,
98 oid: git2::Oid,
99 kind: ReadmeKind,
100 }
101
102 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
103 enum ReadmeKind {
104 Markdown,
105 Plain,
106 }
107
108 /// Walk the root tree (non-recursive) and pick the highest-precedence README
109 /// blob entry. Symlinks and tree entries are ignored.
110 fn find_readme_entry(tree: &git2::Tree) -> Option<ReadmeEntry> {
111 // Score: lower is better. None means "not a README".
112 fn classify(name: &str) -> Option<(u8, ReadmeKind)> {
113 match name.to_ascii_lowercase().as_str() {
114 "readme.md" => Some((0, ReadmeKind::Markdown)),
115 "readme" => Some((1, ReadmeKind::Plain)),
116 "readme.txt" => Some((2, ReadmeKind::Plain)),
117 _ => None,
118 }
119 }
120
121 let mut best: Option<(u8, ReadmeEntry)> = None;
122 for entry in tree.iter() {
123 // Skip subtrees, symlinks, submodules — only regular blobs.
124 if entry.kind() != Some(git2::ObjectType::Blob) {
125 continue;
126 }
127 if entry.filemode() != 0o100644 && entry.filemode() != 0o100755 {
128 continue; // 0o120000 (symlink) and anything else
129 }
130 let name = match entry.name() {
131 Some(n) => n,
132 None => continue,
133 };
134 let (score, kind) = match classify(name) {
135 Some(v) => v,
136 None => continue,
137 };
138 let candidate = ReadmeEntry { name: name.to_string(), oid: entry.id(), kind };
139 match &best {
140 None => best = Some((score, candidate)),
141 Some((cur_score, _)) if score < *cur_score => best = Some((score, candidate)),
142 _ => {}
143 }
144 }
145 best.map(|(_, e)| e)
146 }
147
148 fn render_markdown(src: &str) -> String {
149 use pulldown_cmark::{Options, Parser, html};
150 let mut opts = Options::empty();
151 opts.insert(Options::ENABLE_TABLES);
152 opts.insert(Options::ENABLE_STRIKETHROUGH);
153 opts.insert(Options::ENABLE_TASKLISTS);
154 let parser = Parser::new_ext(src, opts);
155 let mut unsafe_html = String::new();
156 html::push_html(&mut unsafe_html, parser);
157 sanitize(&unsafe_html)
158 }
159
160 fn render_plain(src: &str) -> String {
161 // Task 4 will tighten this. For now, escape and wrap.
162 let escaped = html_escape(src);
163 format!("<pre>{}</pre>", escaped)
164 }
165
166 fn html_escape(s: &str) -> String {
167 s.replace('&', "&amp;")
168 .replace('<', "&lt;")
169 .replace('>', "&gt;")
170 .replace('"', "&quot;")
171 .replace('\'', "&#39;")
172 }
173
174 fn sanitize(html: &str) -> String {
175 use std::collections::HashSet;
176
177 // Restrict the URL scheme allowlist used by ammonia for href/src and the
178 // other URL-bearing attributes it tracks. This drops data: image URIs as
179 // well as javascript: in any href, including <a href>, which are the
180 // attack vectors that matter for a README.
181 let mut schemes: HashSet<&str> = HashSet::new();
182 schemes.insert("http");
183 schemes.insert("https");
184 schemes.insert("mailto");
185
186 ammonia::Builder::default()
187 .url_schemes(schemes)
188 .clean(html)
189 .to_string()
190 }
191
192 #[cfg(test)]
193 mod tests {
194 use super::*;
195 use git2::Repository;
196 use tempfile::TempDir;
197
198 fn load(repo: &Repository) -> Option<RenderedReadme> {
199 let branch = repo.head().ok()
200 .and_then(|h| h.shorthand().map(String::from))
201 .unwrap_or_else(|| "main".to_string());
202 load_readme(repo, "test-repo", &branch)
203 }
204
205 /// Build an empty git repo with a single commit containing the given
206 /// (path, contents) blobs at the root tree. Returns the repo and the
207 /// tempdir (kept alive by the caller).
208 fn repo_with_files(files: &[(&str, &[u8])]) -> (Repository, TempDir) {
209 let tmp = TempDir::new().unwrap();
210 let repo = Repository::init(tmp.path()).unwrap();
211
212 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
213 {
214 let tree_oid = {
215 let mut builder = repo.treebuilder(None).unwrap();
216 for (name, contents) in files {
217 let oid = repo.blob(contents).unwrap();
218 builder.insert(name, oid, 0o100644).unwrap();
219 }
220 builder.write().unwrap()
221 };
222 let tree = repo.find_tree(tree_oid).unwrap();
223 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
224 }
225
226 (repo, tmp)
227 }
228
229 #[test]
230 fn no_readme_returns_none() {
231 let (repo, _tmp) = repo_with_files(&[("lib.rs", b"fn main() {}")]);
232 assert!(load(&repo).is_none());
233 }
234
235 #[test]
236 fn finds_uppercase_readme_md() {
237 let (repo, _tmp) = repo_with_files(&[("README.md", b"# Title\n\nbody\n")]);
238 let r = load(&repo).expect("README found");
239 assert!(r.html.contains("<h1>Title</h1>"), "got: {}", r.html);
240 }
241
242 #[test]
243 fn finds_mixed_case_readme_md() {
244 let (repo, _tmp) = repo_with_files(&[("Readme.MD", b"# T\n")]);
245 assert!(load(&repo).is_some());
246 }
247
248 #[test]
249 fn md_wins_over_txt() {
250 let (repo, _tmp) = repo_with_files(&[
251 ("README.md", b"# md\n"),
252 ("README.txt", b"plain"),
253 ]);
254 let r = load(&repo).unwrap();
255 assert!(r.html.contains("<h1>md</h1>"));
256 assert!(!r.html.contains("plain"));
257 }
258
259 #[test]
260 fn readme_wins_over_txt() {
261 let (repo, _tmp) = repo_with_files(&[
262 ("README", b"plain readme"),
263 ("README.txt", b"plain txt"),
264 ]);
265 let r = load(&repo).unwrap();
266 assert!(r.html.contains("plain readme"));
267 assert!(!r.html.contains("plain txt"));
268 }
269
270 #[test]
271 fn mixed_case_md_still_wins_over_lowercase_txt() {
272 let (repo, _tmp) = repo_with_files(&[
273 ("README.md", b"# md\n"),
274 ("readme.txt", b"plain"),
275 ]);
276 assert!(load(&repo).unwrap().html.contains("<h1>md</h1>"));
277 }
278
279 #[test]
280 fn nested_readme_is_not_matched() {
281 // Build a tree containing both:
282 // docs/README.md (a subtree entry that must be ignored)
283 // README (a real root-level README that must win)
284 // This proves `find_readme_entry` does not recurse into subtrees,
285 // and that subtree entries with README-like names are skipped at the
286 // root iteration level.
287 let tmp = TempDir::new().unwrap();
288 let repo = Repository::init(tmp.path()).unwrap();
289 let sig = git2::Signature::now("T", "t@e").unwrap();
290
291 // The nested blob whose name happens to match a README.
292 let nested_blob = repo.blob(b"# nested wins (it should not)\n").unwrap();
293 let mut sub = repo.treebuilder(None).unwrap();
294 sub.insert("README.md", nested_blob, 0o100644).unwrap();
295 let sub_oid = sub.write().unwrap();
296
297 // The real root README.
298 let root_blob = repo.blob(b"actual root readme\n").unwrap();
299
300 let mut root = repo.treebuilder(None).unwrap();
301 root.insert("docs", sub_oid, 0o040000).unwrap();
302 root.insert("README", root_blob, 0o100644).unwrap();
303 let root_oid = root.write().unwrap();
304 let tree = repo.find_tree(root_oid).unwrap();
305 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
306
307 let r = load(&repo).expect("root README must be found");
308 assert!(r.html.contains("actual root readme"));
309 assert!(!r.html.contains("nested wins"));
310 }
311
312 #[test]
313 fn plain_readme_escapes_script_tag() {
314 let (repo, _tmp) = repo_with_files(&[
315 ("README", b"<script>alert(1)</script>\nhello"),
316 ]);
317 let r = load(&repo).unwrap();
318 assert!(r.html.starts_with("<pre>"));
319 assert!(r.html.contains("&lt;script&gt;"));
320 assert!(!r.html.contains("<script>"));
321 assert!(r.html.contains("hello"));
322 }
323
324 #[test]
325 fn symlink_readme_is_ignored() {
326 let tmp = TempDir::new().unwrap();
327 let repo = Repository::init(tmp.path()).unwrap();
328 let sig = git2::Signature::now("T", "t@e").unwrap();
329
330 // Symlinks are stored as a blob whose contents are the target path,
331 // with file mode 0o120000.
332 let target_blob = repo.blob(b"docs/REAL.md").unwrap();
333 let mut root = repo.treebuilder(None).unwrap();
334 root.insert("README.md", target_blob, 0o120000).unwrap();
335 let root_oid = root.write().unwrap();
336 let tree = repo.find_tree(root_oid).unwrap();
337 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
338
339 assert!(load(&repo).is_none());
340 }
341
342 #[test]
343 fn markdown_strips_script_tag() {
344 // Passes because pulldown-cmark does not emit raw HTML without
345 // ENABLE_UNSAFE_HTML — defense-in-depth canary, not an ammonia test.
346 let (repo, _tmp) = repo_with_files(&[
347 ("README.md", b"# t\n\n<script>alert(1)</script>\n"),
348 ]);
349 let html = load(&repo).unwrap().html;
350 assert!(!html.contains("<script>"), "got: {}", html);
351 }
352
353 #[test]
354 fn markdown_strips_javascript_href() {
355 // Passes because pulldown-cmark does not emit raw HTML without
356 // ENABLE_UNSAFE_HTML — defense-in-depth canary, not an ammonia test.
357 let (repo, _tmp) = repo_with_files(&[
358 ("README.md", b"[click](javascript:alert(1))\n"),
359 ]);
360 let html = load(&repo).unwrap().html;
361 assert!(!html.contains("javascript:"), "got: {}", html);
362 }
363
364 #[test]
365 fn markdown_strips_onerror_attribute() {
366 // Passes because pulldown-cmark does not emit raw HTML without
367 // ENABLE_UNSAFE_HTML — defense-in-depth canary, not an ammonia test.
368 let (repo, _tmp) = repo_with_files(&[
369 ("README.md", b"<img src=\"https://x/y.png\" onerror=\"alert(1)\">\n"),
370 ]);
371 let html = load(&repo).unwrap().html;
372 assert!(!html.contains("onerror"), "got: {}", html);
373 }
374
375 #[test]
376 fn markdown_strips_iframe() {
377 // Passes because pulldown-cmark does not emit raw HTML without
378 // ENABLE_UNSAFE_HTML — defense-in-depth canary, not an ammonia test.
379 let (repo, _tmp) = repo_with_files(&[
380 ("README.md", b"<iframe src=\"https://evil.example/\"></iframe>\n"),
381 ]);
382 let html = load(&repo).unwrap().html;
383 assert!(!html.contains("<iframe"), "got: {}", html);
384 }
385
386 #[test]
387 fn markdown_strips_data_image_uri() {
388 let (repo, _tmp) = repo_with_files(&[
389 ("README.md", b"<img src=\"data:image/png;base64,AAAA\">\n"),
390 ]);
391 let html = load(&repo).unwrap().html;
392 // Either the whole <img> is dropped or the src attr is gone.
393 assert!(!html.contains("data:"), "got: {}", html);
394 }
395
396 #[test]
397 fn empty_readme_renders_empty_body() {
398 let (repo, _tmp) = repo_with_files(&[("README.md", b"")]);
399 let r = load(&repo).expect("present-but-empty is Some");
400 // ammonia of empty markdown is the empty string; assert it's not the
401 // too-large notice and not None.
402 assert!(!r.html.contains("too large"));
403 }
404
405 #[test]
406 fn oversized_blob_returns_too_large_notice() {
407 let big = vec![b'x'; 600 * 1024];
408 let (repo, _tmp) = repo_with_files(&[("README.md", &big)]);
409 let r = load(&repo).unwrap();
410 assert!(r.html.contains("too large"));
411 assert!(r.html.contains("/blob/"));
412 assert!(r.html.contains("README.md"));
413 }
414
415 #[test]
416 fn markdown_bomb_post_render_cap_trips() {
417 // Code-span paragraphs: "`a`\n\n" (5 bytes each) expand to
418 // "<p><code>a</code></p>\n" (~22 bytes each) — a 4.4x ratio.
419 // At 100_000 reps: source = 500_000 bytes < 512 KiB (524_288),
420 // rendered ≈ 2_200_000 bytes > 2 MiB (2_097_152).
421 // Calibrated empirically: ratio confirmed at 4.40x.
422 let mut src = String::new();
423 for _ in 0..100_000usize {
424 src.push_str("`a`\n\n");
425 }
426 assert!(src.len() < MAX_BLOB_BYTES, "source {} >= blob cap {}", src.len(), MAX_BLOB_BYTES);
427 let (repo, _tmp) = repo_with_files(&[("README.md", src.as_bytes())]);
428 let r = load(&repo).unwrap();
429 assert!(r.html.contains("too large"), "expected bomb to trip cap");
430 }
431
432 #[test]
433 fn plain_text_post_render_cap_trips() {
434 // 500 KiB of '&' → ~2.5 MiB of "&amp;" inside a <pre>.
435 let bytes = vec![b'&'; 500 * 1024];
436 let (repo, _tmp) = repo_with_files(&[("README", &bytes)]);
437 let r = load(&repo).unwrap();
438 assert!(r.html.contains("too large"), "expected plain-text bomb to trip cap");
439 }
440
441 #[test]
442 fn binary_blob_returns_none() {
443 let (repo, _tmp) = repo_with_files(&[("README.md", &[0u8, 1, 2, 3, 0xff, 0xfe])]);
444 assert!(load(&repo).is_none());
445 }
446
447 #[test]
448 fn invalid_utf8_returns_none() {
449 // Mostly valid text + a stray 0x80 byte. Not flagged as binary by git2's
450 // heuristic (no NULs), but not valid UTF-8 either.
451 let mut bytes = Vec::from(&b"hello world\nmore text\n"[..]);
452 bytes.push(0x80);
453 bytes.extend_from_slice(b"\nmore\n");
454 let (repo, _tmp) = repo_with_files(&[("README.md", &bytes)]);
455 assert!(load(&repo).is_none());
456 }
457 }
src/server/http/templates/repo_overview.html
Old New
@@ -3,6 +3,12 @@
3 {% block title %}{{ repo_name }} — {{ site_title }}{% endblock %} 3 {% block title %}{{ repo_name }} — {{ site_title }}{% endblock %}
4 4
5 {% block content %} 5 {% block content %}
6 {% if let Some(r) = readme %}
7 <div style="border: 1px solid #ccc; padding: 16px; margin-bottom: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.5;">
8 <h3 style="margin-top: 0; font-family: monospace;">README</h3>
9 <div class="readme-body">{{ r.html|safe }}</div>
10 </div>
11 {% endif %}
6 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;"> 12 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;">
7 <div> 13 <div>
8 <h3 style="margin-top: 0;">Open Patches</h3> 14 <h3 style="margin-top: 0;">Open Patches</h3>
tests/server_behavior_test.rs
Old New
@@ -153,3 +153,38 @@ fn missing_repository_and_missing_objects_return_not_found() {
153 assert!(missing_blob.body.contains("src/missing.rs")); 153 assert!(missing_blob.body.contains("src/missing.rs"));
154 assert!(missing_blob.body.contains("not found")); 154 assert!(missing_blob.body.contains("not found"));
155 } 155 }
156
157 #[test]
158 fn readme_md_renders_on_repo_overview_page() {
159 let harness = ServerHarness::new("behavior-readme");
160
161 harness.work_repo().commit_file(
162 "README.md",
163 "# Hello World\n\nA short description with a [link](https://example.com).\n",
164 "add README",
165 );
166 harness.push_head();
167
168 let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
169 assert!(overview.body.contains("<h1>Hello World</h1>"), "missing h1: {}", overview.body);
170 assert!(overview.body.contains("href=\"https://example.com\""));
171 // Sanity: the new card wrapper exists.
172 assert!(overview.body.contains("class=\"readme-body\""));
173 }
174
175 #[test]
176 fn missing_readme_does_not_break_overview_page() {
177 let harness = ServerHarness::new("behavior-no-readme");
178
179 harness.work_repo().commit_file(
180 "src/lib.rs",
181 "pub fn x() {}\n",
182 "add lib",
183 );
184 harness.push_head();
185
186 let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
187 assert!(!overview.body.contains("class=\"readme-body\""));
188 // The rest of the page still renders.
189 assert!(overview.body.contains("Open Patches") || overview.body.contains("Recent Commits"));
190 }