c5c9e075
Render the README on the repo overview page
a73x 2026-08-08 16:57
Commit message
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('&', "&") | ||
| 378 | .replace('<', "<") | ||
| 379 | .replace('>', ">") | ||
| 380 | .replace('"', """) | ||
| 381 | .replace('\'', "'") | ||
| 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("<script>")); | ||
| 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 `<script>`, 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('&', "&") | ||
| 168 | .replace('<', "<") | ||
| 169 | .replace('>', ">") | ||
| 170 | .replace('"', """) | ||
| 171 | .replace('\'', "'") | ||
| 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("<script>")); | ||
| 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 "&" 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 | } | ||