rustforge
RustForge: the read-only, cgit-style git web host serving this site
| author | Tyler Fowler <tyler@localhost> | 2026-10-31 15:40:24 -0700 |
|---|---|---|
| commit | 28ce798b92b4c44479cdd8cbabaa9c9c1078f81e | |
| tree | browse files at this commit | |
forge v0.1: read-only cgit-style git web host Serves the repos listed in forge.toml (bare or non-bare) with an index, summary (branches, tags, recent commits, rendered README), paginated log, commit view with a colored unified diff, tree browser, syntax-highlighted blob view with line numbers, raw downloads and a refs page. git clone works over smart HTTP (protocol v0 and v2) by piping requests through 'git upload-pack --stateless-rpc'. Push is refused. Safety: only configured repos are served, URL paths are resolved inside git trees after rejecting '..', refs are limited to plain names and hex ids, all output is escaped, README raw HTML is neutralised, and every response carries a no-script Content-Security-Policy. Includes unit tests and an integration test that builds temp repos, checks every page and does real clones.
| A | .gitignore | +1 -0 |
| A | Cargo.lock | +1114 -0 |
| A | Cargo.toml | +35 -0 |
| A | README.md | +143 -0 |
| A | forge.toml | +14 -0 |
| A | rustfmt.toml | +2 -0 |
| A | src/config.rs | +199 -0 |
| A | src/git.rs | +393 -0 |
| A | src/highlight.rs | +105 -0 |
| A | src/lib.rs | +20 -0 |
| A | src/main.rs | +62 -0 |
| A | src/markdown.rs | +105 -0 |
| A | src/pages.rs | +404 -0 |
| A | src/smart_http.rs | +209 -0 |
| A | src/util.rs | +183 -0 |
| A | src/web.rs | +404 -0 |
| A | static/style.css | +80 -0 |
| A | tests/integration.rs | +275 -0 |
18 files changed, 3748 insertions, 0 deletions
@@ -0,0 +1 @@+target/
@@ -0,0 +1,1114 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "aho-corasick"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"+dependencies = [+ "memchr",+]++[[package]]+name = "atomic-waker"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"++[[package]]+name = "axum"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"+dependencies = [+ "axum-core",+ "bytes",+ "form_urlencoded",+ "futures-util",+ "http",+ "http-body",+ "http-body-util",+ "hyper",+ "hyper-util",+ "itoa",+ "matchit",+ "memchr",+ "mime",+ "percent-encoding",+ "pin-project-lite",+ "serde_core",+ "serde_json",+ "serde_path_to_error",+ "serde_urlencoded",+ "sync_wrapper",+ "tokio",+ "tower",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "axum-core"+version = "0.5.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "http-body-util",+ "mime",+ "pin-project-lite",+ "sync_wrapper",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "base64"+version = "0.23.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"++[[package]]+name = "bincode"+version = "1.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"+dependencies = [+ "serde",+]++[[package]]+name = "bit-set"+version = "0.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"+dependencies = [+ "bit-vec",+]++[[package]]+name = "bit-vec"+version = "0.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"++[[package]]+name = "bitflags"+version = "2.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"++[[package]]+name = "bytes"+version = "1.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"++[[package]]+name = "cc"+version = "1.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"+dependencies = [+ "find-msvc-tools",+ "jobserver",+ "libc",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"++[[package]]+name = "crc32fast"+version = "1.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"+dependencies = [+ "cfg-if",+]++[[package]]+name = "deranged"+version = "0.5.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"++[[package]]+name = "equivalent"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"++[[package]]+name = "errno"+version = "0.3.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "fancy-regex"+version = "0.16.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"+dependencies = [+ "bit-set",+ "regex-automata",+ "regex-syntax",+]++[[package]]+name = "fastrand"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"++[[package]]+name = "find-msvc-tools"+version = "0.1.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"++[[package]]+name = "flate2"+version = "1.1.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"+dependencies = [+ "crc32fast",+ "miniz_oxide",+ "zlib-rs",+]++[[package]]+name = "fnv"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"++[[package]]+name = "forge"+version = "0.1.0"+dependencies = [+ "axum",+ "flate2",+ "git2",+ "http-body-util",+ "maud",+ "pulldown-cmark",+ "serde",+ "syntect",+ "tempfile",+ "tokio",+ "tokio-util",+ "toml",+ "tower",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "futures-core"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"++[[package]]+name = "futures-sink"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"++[[package]]+name = "futures-task"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"++[[package]]+name = "futures-util"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"+dependencies = [+ "futures-core",+ "futures-task",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "getrandom"+version = "0.4.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"+dependencies = [+ "cfg-if",+ "libc",+ "r-efi",+]++[[package]]+name = "git2"+version = "0.21.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e"+dependencies = [+ "bitflags",+ "libc",+ "libgit2-sys",+ "log",+]++[[package]]+name = "hashbrown"+version = "0.17.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"++[[package]]+name = "http"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"+dependencies = [+ "bytes",+ "itoa",+]++[[package]]+name = "http-body"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"+dependencies = [+ "bytes",+ "http",+]++[[package]]+name = "http-body-util"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"+dependencies = [+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "pin-project-lite",+]++[[package]]+name = "httparse"+version = "1.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "hyper"+version = "1.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"+dependencies = [+ "atomic-waker",+ "bytes",+ "futures-core",+ "http",+ "http-body",+ "httparse",+ "httpdate",+ "itoa",+ "pin-project-lite",+ "smallvec",+ "tokio",+]++[[package]]+name = "hyper-util"+version = "0.1.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"+dependencies = [+ "bytes",+ "http",+ "http-body",+ "hyper",+ "pin-project-lite",+ "tokio",+ "tower-service",+]++[[package]]+name = "indexmap"+version = "2.14.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"+dependencies = [+ "equivalent",+ "hashbrown",+]++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "jobserver"+version = "0.1.35"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"+dependencies = [+ "getrandom",+ "libc",+]++[[package]]+name = "libc"+version = "0.2.191"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"++[[package]]+name = "libgit2-sys"+version = "0.18.8+1.9.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"+dependencies = [+ "cc",+ "libc",+ "libz-sys",+ "pkg-config",+]++[[package]]+name = "libz-sys"+version = "1.1.31"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"+dependencies = [+ "cc",+ "libc",+ "pkg-config",+ "vcpkg",+]++[[package]]+name = "linked-hash-map"+version = "0.5.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"++[[package]]+name = "linux-raw-sys"+version = "0.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"++[[package]]+name = "log"+version = "0.4.35"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"++[[package]]+name = "matchit"+version = "0.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"++[[package]]+name = "maud"+version = "0.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"+dependencies = [+ "itoa",+ "maud_macros",+]++[[package]]+name = "maud_macros"+version = "0.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"+dependencies = [+ "proc-macro2",+ "proc-macro2-diagnostics",+ "quote",+ "syn 2.0.119",+]++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "mime"+version = "0.3.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"++[[package]]+name = "miniz_oxide"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "mio"+version = "1.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"+dependencies = [+ "libc",+ "wasi",+ "windows-sys",+]++[[package]]+name = "num-conv"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "pin-project-lite"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"++[[package]]+name = "pkg-config"+version = "0.3.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"++[[package]]+name = "plist"+version = "1.10.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"+dependencies = [+ "base64",+ "indexmap",+ "quick-xml",+ "serde",+ "time",+]++[[package]]+name = "powerfmt"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "proc-macro2-diagnostics"+version = "0.10.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+ "version_check",+]++[[package]]+name = "pulldown-cmark"+version = "0.13.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b"+dependencies = [+ "bitflags",+ "memchr",+ "pulldown-cmark-escape",+ "unicase",+]++[[package]]+name = "pulldown-cmark-escape"+version = "0.11.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"++[[package]]+name = "quick-xml"+version = "0.42.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"+dependencies = [+ "memchr",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "r-efi"+version = "6.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"++[[package]]+name = "regex-automata"+version = "0.4.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"+dependencies = [+ "aho-corasick",+ "memchr",+ "regex-syntax",+]++[[package]]+name = "regex-syntax"+version = "0.8.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"++[[package]]+name = "rustix"+version = "1.1.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"+dependencies = [+ "bitflags",+ "errno",+ "libc",+ "linux-raw-sys",+ "windows-sys",+]++[[package]]+name = "ryu"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"++[[package]]+name = "same-file"+version = "1.0.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"+dependencies = [+ "winapi-util",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.9",+]++[[package]]+name = "serde_json"+version = "1.0.154"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "serde_path_to_error"+version = "0.1.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"+dependencies = [+ "itoa",+ "serde",+ "serde_core",+]++[[package]]+name = "serde_spanned"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"+dependencies = [+ "serde_core",+]++[[package]]+name = "serde_urlencoded"+version = "0.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"+dependencies = [+ "form_urlencoded",+ "itoa",+ "ryu",+ "serde",+]++[[package]]+name = "shlex"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"++[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]++[[package]]+name = "simd-adler32"+version = "0.3.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"++[[package]]+name = "slab"+version = "0.4.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"++[[package]]+name = "smallvec"+version = "1.16.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"++[[package]]+name = "socket2"+version = "0.6.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"+dependencies = [+ "libc",+ "windows-sys",+]++[[package]]+name = "syn"+version = "2.0.119"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "syn"+version = "3.0.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "sync_wrapper"+version = "1.0.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"++[[package]]+name = "syntect"+version = "5.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"+dependencies = [+ "bincode",+ "fancy-regex",+ "flate2",+ "fnv",+ "once_cell",+ "plist",+ "regex-syntax",+ "serde",+ "serde_derive",+ "serde_json",+ "thiserror",+ "walkdir",+ "yaml-rust",+]++[[package]]+name = "tempfile"+version = "3.27.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"+dependencies = [+ "fastrand",+ "getrandom",+ "once_cell",+ "rustix",+ "windows-sys",+]++[[package]]+name = "thiserror"+version = "2.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"+dependencies = [+ "thiserror-impl",+]++[[package]]+name = "thiserror-impl"+version = "2.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.9",+]++[[package]]+name = "time"+version = "0.3.55"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"+dependencies = [+ "deranged",+ "num-conv",+ "powerfmt",+ "serde_core",+ "time-core",+ "time-macros",+]++[[package]]+name = "time-core"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"++[[package]]+name = "time-macros"+version = "0.2.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"+dependencies = [+ "num-conv",+ "time-core",+]++[[package]]+name = "tokio"+version = "1.54.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"+dependencies = [+ "bytes",+ "libc",+ "mio",+ "pin-project-lite",+ "signal-hook-registry",+ "socket2",+ "tokio-macros",+ "windows-sys",+]++[[package]]+name = "tokio-macros"+version = "2.7.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.9",+]++[[package]]+name = "tokio-util"+version = "0.7.20"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"+dependencies = [+ "bytes",+ "futures-core",+ "futures-sink",+ "pin-project-lite",+ "tokio",+]++[[package]]+name = "toml"+version = "1.1.8+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"+dependencies = [+ "indexmap",+ "serde_core",+ "serde_spanned",+ "toml_datetime",+ "toml_parser",+ "toml_writer",+ "winnow",+]++[[package]]+name = "toml_datetime"+version = "1.1.2+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"+dependencies = [+ "serde_core",+]++[[package]]+name = "toml_parser"+version = "1.1.5+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"+dependencies = [+ "winnow",+]++[[package]]+name = "toml_writer"+version = "1.1.3+spec-1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"++[[package]]+name = "tower"+version = "0.5.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"+dependencies = [+ "futures-core",+ "futures-util",+ "pin-project-lite",+ "sync_wrapper",+ "tokio",+ "tower-layer",+ "tower-service",+ "tracing",+]++[[package]]+name = "tower-layer"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"++[[package]]+name = "tower-service"+version = "0.3.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"++[[package]]+name = "tracing"+version = "0.1.44"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"+dependencies = [+ "log",+ "pin-project-lite",+ "tracing-core",+]++[[package]]+name = "tracing-core"+version = "0.1.36"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"+dependencies = [+ "once_cell",+]++[[package]]+name = "unicase"+version = "2.10.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"++[[package]]+name = "unicode-ident"+version = "1.0.27"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"++[[package]]+name = "vcpkg"+version = "0.2.15"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"++[[package]]+name = "version_check"+version = "0.9.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"++[[package]]+name = "walkdir"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"+dependencies = [+ "same-file",+ "winapi-util",+]++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "winapi-util"+version = "0.1.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"+dependencies = [+ "windows-sys",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "winnow"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"++[[package]]+name = "yaml-rust"+version = "0.4.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"+dependencies = [+ "linked-hash-map",+]++[[package]]+name = "zlib-rs"+version = "0.6.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,35 @@+[package]+name = "forge"+version = "0.1.0"+edition = "2021"+description = "A small, read-only, cgit-style git web host"+license = "MIT OR Apache-2.0"+publish = false++[dependencies]+# Web server+axum = "0.8"+tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "process", "io-util", "signal", "fs"] }+tokio-util = { version = "0.7", features = ["io"] }+# Reading git repositories (libgit2 is built from source; no OpenSSL/SSH needed+# because we only ever read local repos).+git2 = { version = "0.21", default-features = false }+# Markdown README rendering+pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }+# Syntax highlighting (pure-Rust regex engine, no C oniguruma dependency)+syntect = { version = "5", default-features = false, features = ["default-fancy"] }+# HTML templates checked at compile time; everything is escaped by default+maud = "0.27"+# Config file+serde = { version = "1", features = ["derive"] }+toml = "1"+# git clients may gzip the body of a clone request+flate2 = "1"++[dev-dependencies]+tempfile = "3"+tower = { version = "0.5", features = ["util"] }+http-body-util = "0.1"++[profile.release]+lto = "thin"
@@ -0,0 +1,143 @@+# forge++A small, **read-only**, cgit-style git web host written in Rust.+Version 0.1, built to be the self-hosted home for Tyler's hobby projects+(starting with the Rustbelt kernel).++> `forge` is a placeholder name. To rename it, change `name` in `Cargo.toml`+> and the `forge::` imports in `src/main.rs` and `tests/integration.rs`.++++## What it does++- **Repo index**: every configured repo with its description and how long since the last commit.+- **Summary**: branches, tags, the 10 latest commits, a `git clone` hint, and the README rendered from Markdown.+- **Log**: 50 commits per page with newer/older links. `?h=<branch|tag|commit>` picks where to start.+- **Commit**: author, message, parents, a diffstat and a unified diff with green/red added/removed lines.+- **Tree and blob**: browse directories. Files get syntax highlighting and clickable line numbers (`#n42`).+- **Raw**: download any file as-is.+- **Refs**: all branches and tags.+- **`git clone` over HTTP** (smart protocol, v0 and v2). Pushing is refused.+- Light and dark themes (follows your OS setting). No JavaScript anywhere.++Screenshots: [index](docs/index.png), [summary](docs/summary.png), [commit diff](docs/commit.png).++## Quick start++```sh+cargo build --release+./target/release/forge forge.toml # or: cargo run --release -- forge.toml+# open http://127.0.0.1:8090/+git clone http://127.0.0.1:8090/rust-kernel+```++Options: `forge [CONFIG.toml] [--listen ADDR]`. The config path defaults to `forge.toml`.++Running it in the background:++```sh+nohup ./target/release/forge forge.toml > /tmp/githost.log 2>&1 &+```++## Configuration++`forge.toml` lists the repos to serve. Nothing else on disk is reachable.++```toml+[server]+listen = "127.0.0.1:8090"+site_title = "Tyler's git"+# clone_base_url = "https://git.example.com" # shown in clone hints++[[repo]]+name = "rust-kernel" # URL: /rust-kernel/+path = "/workspace/rust-kernel" # a normal checkout or a bare foo.git+description = "Rustbelt: a hobby x86_64 kernel written in Rust"+```++`git` must be installed and on `PATH`, because cloning runs `git upload-pack`.++## URLs++| URL | Page |+|-----|------|+| `/` | repo index |+| `/{repo}/` | summary |+| `/{repo}/refs` | branches and tags |+| `/{repo}/log?h=main&ofs=50` | log, page 2 |+| `/{repo}/commit/{id}` | commit and diff (full or short id) |+| `/{repo}/tree/{path}?h=v1.0` | directory |+| `/{repo}/blob/{path}` | highlighted file |+| `/{repo}/raw/{path}` | raw file |+| `/{repo}/info/refs`, `/{repo}/git-upload-pack` | used by `git clone` |++## How it works++A request goes through four steps:++1. **Routing** (`src/web.rs`). [axum](https://docs.rs/axum) matches the URL to a+ handler function, for example `/{repo}/log` goes to `log()`. Each handler+ first looks the repo name up in the config. Unknown names get a 404, which+ is why nothing outside the config can be served.+2. **Reading git** (`src/git.rs`). The handler opens the repo with+ [git2](https://docs.rs/git2) (bindings to libgit2) and collects plain+ structs such as `CommitInfo` and `FileDiff`. libgit2 calls block the+ thread, so they run in `tokio::task::spawn_blocking` and don't hold up other+ requests. File paths from the URL are never used on the real filesystem.+ They are looked up *inside git's tree objects*, after `..` and odd+ characters have been rejected (`util::clean_repo_path`).+3. **Rendering HTML** (`src/pages.rs`). Pages are [maud](https://maud.lambda.xyz)+ templates, which are Rust code checked at compile time. Every `(value)` is+ HTML-escaped automatically. The only pre-built HTML comes from:+ - `src/markdown.rs`: READMEs rendered with pulldown-cmark. Raw HTML in the+ Markdown becomes text, and `javascript:` links are removed.+ - `src/highlight.rs`: syntect, which emits `<span class="hl-…">` with the+ text escaped. Colors come from generated CSS for a light and a dark theme.+4. **Cloning** (`src/smart_http.rs`). Rather than reimplementing git's wire+ protocol, forge runs the real `git upload-pack --stateless-rpc`, which is+ what git's own `git-http-backend` does, and pipes the HTTP request and+ response through it. upload-pack only reads, so a clone can't change the repo.++Every response also carries a strict `Content-Security-Policy` (no scripts at+all) and `X-Content-Type-Options: nosniff` as a second safety net. Raw files+are never served as HTML or SVG.++## Project layout++```+src/main.rs command line, starts the server+src/lib.rs module list+src/config.rs forge.toml parsing and validation+src/web.rs routes + handlers+src/git.rs reading repos with libgit2+src/pages.rs HTML templates+src/markdown.rs README rendering+src/highlight.rs syntax highlighting+src/smart_http.rs git clone support+src/util.rs dates, ages, path/ref checks, URL encoding+static/style.css the stylesheet+tests/integration.rs builds temp repos, checks every page, does real clones+```++## Tests++```sh+cargo test+```++The unit tests cover config parsing, path and ref checks, date formatting,+Markdown sanitizing, and highlighting. The integration test builds a normal and+a bare repo in a temp dir and checks every page type, pagination, escaping and+path traversal attempts. It then starts a real server and runs `git clone`+(protocol v0 and v2) plus a `git push` that must be refused.++## Roadmap++- **Push over SSH**: `git-receive-pack` behind an SSH server with per-user keys (e.g. the `russh` crate, or `authorized_keys` with a forced command).+- **Users and auth**: accounts, private repos, per-repo permissions. Smart-HTTP push could then be added behind auth.+- **Nicer browsing**: blame, a branch/tag selector, commit search, diffs between any two refs, tarball snapshots, Atom feed of commits.+- **Speed**: cache rendered pages and the "idle" times, keyed by ref tips.+- **Public deploy**: a reverse proxy (Caddy or nginx) with TLS in front of `127.0.0.1:8090`, `clone_base_url` set, and a systemd unit.+- **Issues, later**: a minimal issue tracker, or a mailing-list style flow.+- **Self-hosting on Rustbelt**: once the kernel has a TCP stack, sockets and `fork/exec`, run forge (or a cut-down build) on Rustbelt itself.
@@ -0,0 +1,14 @@+# forge configuration.+# Only the repositories listed here are served. Paths may point at a bare repo+# (foo.git) or a normal checkout (foo/ containing .git/).++[server]+listen = "127.0.0.1:8090"+site_title = "Tyler's git"+# Base URL shown in "git clone" hints. Leave unset to use the request's Host.+# clone_base_url = "https://git.example.com"++[[repo]]+name = "rust-kernel"+path = "/workspace/rust-kernel"+description = "Rustbelt: a hobby x86_64 kernel written in Rust"
@@ -0,0 +1,2 @@+max_width = 110+use_small_heuristics = "Max"
@@ -0,0 +1,199 @@+//! Loading and validating the TOML config file.+//!+//! The config is the *only* thing that decides which repositories are served.+//! A request for a repo name that is not listed here gets a 404, so nobody can+//! ask the server to read an arbitrary directory on disk.++use serde::Deserialize;+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++/// The whole config file, as written by the user.+#[derive(Debug, Clone, Deserialize)]+#[serde(deny_unknown_fields)]+pub struct Config {+ #[serde(default)]+ pub server: ServerConfig,+ /// Each `[[repo]]` table in the TOML file becomes one entry here.+ #[serde(default, rename = "repo")]+ pub repos: Vec<RepoConfig>,+}++#[derive(Debug, Clone, Deserialize)]+#[serde(deny_unknown_fields)]+pub struct ServerConfig {+ /// Address to listen on, e.g. "127.0.0.1:8090".+ #[serde(default = "default_listen")]+ pub listen: String,+ /// Title shown at the top of every page.+ #[serde(default = "default_title")]+ pub site_title: String,+ /// Base URL shown in "git clone ..." hints, e.g. "https://git.example.com".+ /// When unset, the `Host` header of the request is used.+ #[serde(default)]+ pub clone_base_url: Option<String>,+}++impl Default for ServerConfig {+ fn default() -> Self {+ ServerConfig { listen: default_listen(), site_title: default_title(), clone_base_url: None }+ }+}++fn default_listen() -> String {+ "127.0.0.1:8090".to_string()+}++fn default_title() -> String {+ "git".to_string()+}++#[derive(Debug, Clone, Deserialize)]+#[serde(deny_unknown_fields)]+pub struct RepoConfig {+ /// URL name, e.g. "rust-kernel" -> http://host/rust-kernel/+ pub name: String,+ /// Path on disk: a bare repo (`foo.git`) or a normal checkout (`foo/`).+ pub path: PathBuf,+ #[serde(default)]+ pub description: String,+}++/// The validated, ready-to-use set of repos, looked up by name.+#[derive(Debug, Clone)]+pub struct Repos {+ by_name: BTreeMap<String, RepoConfig>,+}++impl Repos {+ pub fn get(&self, name: &str) -> Option<&RepoConfig> {+ // Allow `/name.git/...` as an alias for `/name/...` (people expect it in clone URLs).+ let name = name.strip_suffix(".git").unwrap_or(name);+ self.by_name.get(name)+ }++ /// All repos, sorted by name.+ pub fn iter(&self) -> impl Iterator<Item = &RepoConfig> {+ self.by_name.values()+ }++ pub fn len(&self) -> usize {+ self.by_name.len()+ }++ pub fn is_empty(&self) -> bool {+ self.by_name.is_empty()+ }+}++impl Config {+ /// Parse a config from TOML text.+ pub fn from_toml(text: &str) -> Result<Config, String> {+ toml::from_str(text).map_err(|e| format!("invalid config: {e}"))+ }++ /// Read and parse a config file from disk.+ pub fn load(path: &Path) -> Result<Config, String> {+ let text =+ std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;+ Config::from_toml(&text)+ }++ /// Check names and paths, and build the lookup table.+ ///+ /// Paths are canonicalized (symlinks and `..` resolved) once, here, so the+ /// rest of the program only ever deals with fixed absolute paths.+ pub fn validate(&self) -> Result<Repos, String> {+ let mut by_name = BTreeMap::new();+ for repo in &self.repos {+ let name = repo.name.strip_suffix(".git").unwrap_or(&repo.name);+ if !is_valid_repo_name(name) {+ return Err(format!(+ "repo name {:?} is not allowed (use letters, digits, '-', '_' and '.', not starting with '.')",+ repo.name+ ));+ }+ let path = std::fs::canonicalize(&repo.path)+ .map_err(|e| format!("repo {name:?}: path {}: {e}", repo.path.display()))?;+ let fixed = RepoConfig { name: name.to_string(), path, description: repo.description.clone() };+ if by_name.insert(name.to_string(), fixed).is_some() {+ return Err(format!("repo name {name:?} is listed twice"));+ }+ }+ Ok(Repos { by_name })+ }+}++/// Repo names end up in URLs, so keep them boring.+pub fn is_valid_repo_name(name: &str) -> bool {+ !name.is_empty()+ && name.len() <= 100+ && !name.starts_with('.')+ && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn parses_full_config() {+ let cfg = Config::from_toml(+ r#"+ [server]+ listen = "0.0.0.0:9000"+ site_title = "my git"++ [[repo]]+ name = "one"+ path = "/tmp"+ description = "first"+ "#,+ )+ .unwrap();+ assert_eq!(cfg.server.listen, "0.0.0.0:9000");+ assert_eq!(cfg.server.site_title, "my git");+ assert_eq!(cfg.repos.len(), 1);+ assert_eq!(cfg.repos[0].description, "first");+ }++ #[test]+ fn defaults_apply() {+ let cfg = Config::from_toml("").unwrap();+ assert_eq!(cfg.server.listen, "127.0.0.1:8090");+ assert!(cfg.repos.is_empty());+ }++ #[test]+ fn rejects_unknown_keys() {+ assert!(Config::from_toml("bogus = 1").is_err());+ }++ #[test]+ fn repo_names() {+ assert!(is_valid_repo_name("rust-kernel"));+ assert!(is_valid_repo_name("a_b.c"));+ assert!(!is_valid_repo_name(""));+ assert!(!is_valid_repo_name(".."));+ assert!(!is_valid_repo_name(".hidden"));+ assert!(!is_valid_repo_name("a/b"));+ assert!(!is_valid_repo_name("a b"));+ assert!(!is_valid_repo_name("<script>"));+ }++ #[test]+ fn validate_rejects_bad_and_duplicate_names() {+ let bad = Config::from_toml("[[repo]]\nname = \"../x\"\npath = \"/tmp\"").unwrap();+ assert!(bad.validate().is_err());+ let dup = Config::from_toml(+ "[[repo]]\nname = \"x\"\npath = \"/tmp\"\n[[repo]]\nname = \"x.git\"\npath = \"/tmp\"",+ )+ .unwrap();+ assert!(dup.validate().is_err());+ let ok = Config::from_toml("[[repo]]\nname = \"x.git\"\npath = \"/tmp\"").unwrap();+ let repos = ok.validate().unwrap();+ assert!(repos.get("x").is_some());+ assert!(repos.get("x.git").is_some());+ assert!(repos.get("y").is_none());+ }+}
@@ -0,0 +1,393 @@+//! Everything that reads from a git repository lives here.+//!+//! Functions take a `git2::Repository` and return plain Rust structs, so the+//! HTML code never has to know about libgit2. `git2::Repository` can't be+//! shared between threads, so the web handlers open a fresh one per request+//! (that's cheap: it just reads a few files).++use git2::{DiffFindOptions, DiffOptions, ObjectType, Oid, Repository, Sort};+use std::path::Path;++use crate::util;++/// Errors from this module. `NotFound` becomes a 404 page, `Git` a 500.+#[derive(Debug)]+pub enum Error {+ NotFound(String),+ Git(git2::Error),+}++impl From<git2::Error> for Error {+ fn from(e: git2::Error) -> Self {+ if e.code() == git2::ErrorCode::NotFound {+ Error::NotFound(e.message().to_string())+ } else {+ Error::Git(e)+ }+ }+}++pub type Result<T> = std::result::Result<T, Error>;++/// Open a bare (`foo.git`) or normal (`foo/.git`) repository at exactly this+/// path. `NO_SEARCH` stops libgit2 from walking up into parent directories.+pub fn open(path: &Path) -> Result<Repository> {+ let flags = git2::RepositoryOpenFlags::NO_SEARCH;+ Ok(Repository::open_ext(path, flags, std::iter::empty::<&str>())?)+}++#[derive(Debug, Clone)]+pub struct CommitInfo {+ pub id: String,+ pub short_id: String,+ pub summary: String,+ pub message: String,+ pub author_name: String,+ pub author_email: String,+ pub time: i64,+ pub offset_minutes: i32,+ pub parents: Vec<String>,+}++impl CommitInfo {+ fn from_commit(c: &git2::Commit) -> CommitInfo {+ let id = c.id().to_string();+ let author = c.author();+ CommitInfo {+ short_id: id[..10.min(id.len())].to_string(),+ id,+ summary: String::from_utf8_lossy(c.summary_bytes().unwrap_or(b"")).into_owned(),+ message: String::from_utf8_lossy(c.message_bytes()).into_owned(),+ author_name: String::from_utf8_lossy(author.name_bytes()).into_owned(),+ author_email: String::from_utf8_lossy(author.email_bytes()).into_owned(),+ time: c.time().seconds(),+ offset_minutes: c.time().offset_minutes(),+ parents: c.parent_ids().map(|p| p.to_string()).collect(),+ }+ }++ pub fn age(&self) -> String {+ util::age(util::now_unix() - self.time)+ }++ pub fn date(&self) -> String {+ util::format_time(self.time, self.offset_minutes)+ }+}++/// A branch or tag and the commit it points to.+#[derive(Debug, Clone)]+pub struct RefInfo {+ pub name: String,+ pub commit: CommitInfo,+}++/// Turn a user-supplied ref (`?h=...`) into a commit id.+///+/// `None` means "the default branch" (HEAD). We only accept branch names, tag+/// names and hex commit ids, never general git revision syntax.+pub fn resolve(repo: &Repository, rev: Option<&str>) -> Result<Oid> {+ let Some(rev) = rev.filter(|r| !r.is_empty()) else {+ return default_commit(repo);+ };+ if !util::is_safe_ref(rev) {+ return Err(Error::NotFound(format!("bad ref {rev:?}")));+ }+ for full in [format!("refs/heads/{rev}"), format!("refs/tags/{rev}")] {+ if let Ok(r) = repo.find_reference(&full) {+ return Ok(r.peel_to_commit()?.id());+ }+ }+ let is_hex = rev.len() >= 4 && rev.len() <= 40 && rev.chars().all(|c| c.is_ascii_hexdigit());+ if is_hex {+ if let Ok(obj) = repo.revparse_single(rev) {+ if let Ok(c) = obj.peel_to_commit() {+ return Ok(c.id());+ }+ }+ }+ Err(Error::NotFound(format!("no such ref {rev:?}")))+}++/// HEAD's commit, or (if HEAD points at a branch that doesn't exist yet) the+/// first branch we can find.+fn default_commit(repo: &Repository) -> Result<Oid> {+ if let Ok(head) = repo.head() {+ if let Ok(c) = head.peel_to_commit() {+ return Ok(c.id());+ }+ }+ for b in repo.branches(Some(git2::BranchType::Local))? {+ let (b, _) = b?;+ if let Ok(c) = b.get().peel_to_commit() {+ return Ok(c.id());+ }+ }+ Err(Error::NotFound("repository has no commits".into()))+}++pub fn commit(repo: &Repository, id: Oid) -> Result<CommitInfo> {+ Ok(CommitInfo::from_commit(&repo.find_commit(id)?))+}++/// Local branches, newest commit first.+pub fn branches(repo: &Repository) -> Result<Vec<RefInfo>> {+ let mut out = Vec::new();+ for b in repo.branches(Some(git2::BranchType::Local))? {+ let (b, _) = b?;+ let Ok(Some(name)) = b.name().map(|n| n.map(str::to_string)) else { continue };+ if let Ok(c) = b.get().peel_to_commit() {+ out.push(RefInfo { name, commit: CommitInfo::from_commit(&c) });+ }+ }+ out.sort_by(|a, b| b.commit.time.cmp(&a.commit.time).then(a.name.cmp(&b.name)));+ Ok(out)+}++/// Tags (lightweight and annotated), newest commit first.+pub fn tags(repo: &Repository) -> Result<Vec<RefInfo>> {+ let mut out = Vec::new();+ for r in repo.references_glob("refs/tags/*")? {+ let r = r?;+ let Ok(name) = r.shorthand().map(str::to_string) else { continue };+ if let Ok(c) = r.peel_to_commit() {+ out.push(RefInfo { name, commit: CommitInfo::from_commit(&c) });+ }+ }+ out.sort_by(|a, b| b.commit.time.cmp(&a.commit.time).then(a.name.cmp(&b.name)));+ Ok(out)+}++/// Time of the newest commit on any branch (for the "idle" column).+pub fn last_change(repo: &Repository) -> Option<i64> {+ let from_branches = branches(repo).ok()?.into_iter().map(|b| b.commit.time).max();+ let from_head = repo.head().ok().and_then(|h| h.peel_to_commit().ok()).map(|c| c.time().seconds());+ from_branches.max(from_head)+}++/// Commits reachable from `start`, newest first. Skips `skip` commits and+/// returns at most `limit`, plus a flag saying whether more exist.+pub fn log(repo: &Repository, start: Oid, skip: usize, limit: usize) -> Result<(Vec<CommitInfo>, bool)> {+ let mut walk = repo.revwalk()?;+ // Newest first, but never show a parent before its child (like `git log`).+ walk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;+ walk.push(start)?;+ let mut out = Vec::new();+ let mut more = false;+ for id in walk.skip(skip) {+ if out.len() == limit {+ more = true;+ break;+ }+ out.push(CommitInfo::from_commit(&repo.find_commit(id?)?));+ }+ Ok((out, more))+}++// ---------------------------------------------------------------- trees ----++#[derive(Debug, Clone, PartialEq, Eq)]+pub enum EntryKind {+ Dir,+ File,+ Symlink,+ Submodule,+}++#[derive(Debug, Clone)]+pub struct TreeEntry {+ pub name: String,+ /// Full path from the repo root, e.g. "src/main.rs".+ pub path: String,+ pub kind: EntryKind,+ pub mode: i32,+ pub size: Option<usize>,+}++/// What lives at a path: a directory listing or a file's bytes.+pub enum PathContent {+ Dir(Vec<TreeEntry>),+ File { data: Vec<u8>, mode: i32 },+}++/// Look up `path` (already cleaned by `util::clean_repo_path`) in a commit.+pub fn path_content(repo: &Repository, commit: Oid, path: &str) -> Result<PathContent> {+ let root = repo.find_commit(commit)?.tree()?;+ if path.is_empty() {+ return Ok(PathContent::Dir(list_tree(repo, &root, "")?));+ }+ let entry =+ root.get_path(Path::new(path)).map_err(|_| Error::NotFound(format!("no such path {path:?}")))?;+ match entry.kind() {+ Some(ObjectType::Tree) => {+ let tree = repo.find_tree(entry.id())?;+ Ok(PathContent::Dir(list_tree(repo, &tree, path)?))+ }+ Some(ObjectType::Blob) => {+ let blob = repo.find_blob(entry.id())?;+ Ok(PathContent::File { data: blob.content().to_vec(), mode: entry.filemode() })+ }+ _ => Err(Error::NotFound(format!("{path:?} is a submodule"))),+ }+}++fn list_tree(repo: &Repository, tree: &git2::Tree, prefix: &str) -> Result<Vec<TreeEntry>> {+ let odb = repo.odb()?;+ let mut out: Vec<TreeEntry> = tree+ .iter()+ .map(|e| {+ let name = String::from_utf8_lossy(e.name_bytes()).into_owned();+ let kind = match (e.kind(), e.filemode()) {+ (Some(ObjectType::Tree), _) => EntryKind::Dir,+ (Some(ObjectType::Commit), _) => EntryKind::Submodule,+ (_, 0o120000) => EntryKind::Symlink,+ _ => EntryKind::File,+ };+ let size = if kind == EntryKind::File {+ odb.read_header(e.id()).ok().map(|(size, _)| size)+ } else {+ None+ };+ let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") };+ TreeEntry { name, path, kind, mode: e.filemode(), size }+ })+ .collect();+ // Directories first, then files, each alphabetically (like most git web UIs).+ out.sort_by(|a, b| (a.kind != EntryKind::Dir).cmp(&(b.kind != EntryKind::Dir)).then(a.name.cmp(&b.name)));+ Ok(out)+}++/// Find a README at the root of the commit's tree. Returns (file name, text).+pub fn readme(repo: &Repository, commit: Oid) -> Option<(String, String)> {+ let tree = repo.find_commit(commit).ok()?.tree().ok()?;+ for name in ["README.md", "readme.md", "README.markdown", "README", "README.txt"] {+ if let Some(entry) = tree.get_name(name) {+ if let Ok(blob) = repo.find_blob(entry.id()) {+ return Some((name.to_string(), String::from_utf8_lossy(blob.content()).into_owned()));+ }+ }+ }+ None+}++/// Heuristic used by git itself: a NUL byte in the first 8000 bytes means binary.+pub fn is_binary(data: &[u8]) -> bool {+ data.iter().take(8000).any(|&b| b == 0)+}++// ---------------------------------------------------------------- diffs ----++#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum LineKind {+ Context,+ Add,+ Remove,+ HunkHeader,+}++#[derive(Debug, Clone)]+pub struct DiffLine {+ pub kind: LineKind,+ pub text: String,+}++#[derive(Debug, Clone)]+pub struct FileDiff {+ pub old_path: String,+ pub new_path: String,+ /// 'A'dded, 'D'eleted, 'M'odified, 'R'enamed, ...+ pub status: char,+ pub additions: usize,+ pub deletions: usize,+ pub binary: bool,+ pub lines: Vec<DiffLine>,+}++#[derive(Debug, Clone, Default)]+pub struct CommitDiff {+ pub files: Vec<FileDiff>,+ /// True when we stopped early because the diff was huge.+ pub truncated: bool,+}++/// Lines of diff we'll render before giving up (keeps huge commits fast).+const MAX_DIFF_LINES: usize = 20_000;++/// Diff a commit against its first parent (or the empty tree for a root commit).+pub fn commit_diff(repo: &Repository, id: Oid) -> Result<CommitDiff> {+ let commit = repo.find_commit(id)?;+ let new_tree = commit.tree()?;+ let old_tree = match commit.parent(0) {+ Ok(p) => Some(p.tree()?),+ Err(_) => None,+ };+ let mut opts = DiffOptions::new();+ opts.context_lines(3);+ let mut diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;+ diff.find_similar(Some(DiffFindOptions::new().renames(true)))?;++ let mut out = CommitDiff::default();+ let mut total = 0usize;+ for idx in 0..diff.deltas().len() {+ let delta = diff.get_delta(idx).expect("index in range");+ let path_of =+ |f: git2::DiffFile| f.path().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();+ let mut file = FileDiff {+ old_path: path_of(delta.old_file()),+ new_path: path_of(delta.new_file()),+ status: status_char(delta.status()),+ additions: 0,+ deletions: 0,+ binary: delta.flags().is_binary(),+ lines: Vec::new(),+ };+ if let Some(patch) = git2::Patch::from_diff(&diff, idx)? {+ if patch.delta().flags().is_binary() {+ file.binary = true;+ }+ let (_, adds, dels) = patch.line_stats()?;+ file.additions = adds;+ file.deletions = dels;+ for h in 0..patch.num_hunks() {+ if total >= MAX_DIFF_LINES {+ out.truncated = true;+ break;+ }+ let (hunk, n_lines) = patch.hunk(h)?;+ file.lines.push(DiffLine { kind: LineKind::HunkHeader, text: bytes_to_line(hunk.header()) });+ for l in 0..n_lines {+ let line = patch.line_in_hunk(h, l)?;+ let kind = match line.origin() {+ '+' => LineKind::Add,+ '-' => LineKind::Remove,+ ' ' => LineKind::Context,+ _ => continue, // "\ No newline at end of file" markers etc.+ };+ file.lines.push(DiffLine { kind, text: bytes_to_line(line.content()) });+ }+ total += n_lines;+ }+ }+ out.files.push(file);+ }+ Ok(out)+}++/// One-letter status like `git log --name-status` prints.+fn status_char(d: git2::Delta) -> char {+ use git2::Delta::*;+ match d {+ Added => 'A',+ Deleted => 'D',+ Modified => 'M',+ Renamed => 'R',+ Copied => 'C',+ Typechange => 'T',+ _ => '?',+ }+}++fn bytes_to_line(b: &[u8]) -> String {+ let s = String::from_utf8_lossy(b);+ s.trim_end_matches(['\n', '\r']).to_string()+}
@@ -0,0 +1,105 @@+//! Syntax highlighting for the blob view, using syntect.+//!+//! We emit `<span class="hl-...">` tags instead of inline colours. The colours+//! come from two generated stylesheets (light + dark), so the browser picks the+//! right one via `prefers-color-scheme` without any JavaScript.++use syntect::highlighting::ThemeSet;+use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator};+use syntect::parsing::SyntaxSet;+use syntect::util::LinesWithEndings;++const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "hl-" };++/// Files bigger than this are shown without colours (highlighting is slow-ish).+const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024;++pub struct Highlighter {+ syntaxes: SyntaxSet,+ /// CSS for both themes, served at /static/highlight.css+ pub css: String,+}++impl Highlighter {+ pub fn new() -> Highlighter {+ let syntaxes = SyntaxSet::load_defaults_newlines();+ let themes = ThemeSet::load_defaults();+ let light =+ css_for_theme_with_class_style(&themes.themes["InspiredGitHub"], CLASS_STYLE).unwrap_or_default();+ let dark = css_for_theme_with_class_style(&themes.themes["base16-ocean.dark"], CLASS_STYLE)+ .unwrap_or_default();+ let css = format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}\n}}\n");+ Highlighter { syntaxes, css }+ }++ /// Return highlighted HTML for `code`. The result is already escaped and+ /// safe to insert into a page. Falls back to plain escaped text when the+ /// file type is unknown or the file is too large.+ pub fn highlight(&self, file_name: &str, code: &str) -> String {+ let ext = file_name.rsplit('.').next().unwrap_or("");+ let syntax = self+ .syntaxes+ .find_syntax_by_extension(ext)+ .or_else(|| self.syntaxes.find_syntax_by_extension(file_name))+ .or_else(|| self.syntaxes.find_syntax_by_first_line(code.lines().next().unwrap_or("")));+ let syntax = match syntax {+ Some(s) if code.len() <= MAX_HIGHLIGHT_BYTES => s,+ _ => return escape(code),+ };+ let mut gen = ClassedHTMLGenerator::new_with_class_style(syntax, &self.syntaxes, CLASS_STYLE);+ for line in LinesWithEndings::from(code) {+ if gen.parse_html_for_line_which_includes_newline(line).is_err() {+ return escape(code);+ }+ }+ gen.finalize()+ }+}++impl Default for Highlighter {+ fn default() -> Self {+ Highlighter::new()+ }+}++/// Minimal HTML escaping for text placed inside an element.+pub fn escape(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for c in s.chars() {+ match c {+ '&' => out.push_str("&"),+ '<' => out.push_str("<"),+ '>' => out.push_str(">"),+ '"' => out.push_str("""),+ '\'' => out.push_str("'"),+ c => out.push(c),+ }+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn escapes() {+ assert_eq!(escape("<a href=\"x\">&'"), "<a href="x">&'");+ }++ #[test]+ fn highlights_rust_and_escapes() {+ let h = Highlighter::new();+ let out = h.highlight("main.rs", "fn main() { let s = \"<b>\"; }\n");+ assert!(out.contains("hl-"), "expected class spans: {out}");+ assert!(!out.contains("<b>"));+ assert!(out.contains("<b>"));+ assert!(h.css.contains("prefers-color-scheme: dark"));+ }++ #[test]+ fn unknown_type_is_plain_escaped() {+ let h = Highlighter::new();+ assert_eq!(h.highlight("data.zzzunknown", "<x>"), "<x>");+ }+}
@@ -0,0 +1,20 @@+//! forge: a small, read-only, cgit-style git web host.+//!+//! Module map (read them in this order if you're new):+//! - [`config`] – the TOML file that lists which repos to serve+//! - [`git`] – reading commits, trees, diffs with libgit2+//! - [`markdown`] – README rendering (with raw HTML disabled)+//! - [`highlight`] – syntax highlighting for the blob view+//! - [`pages`] – the HTML for every page (maud templates)+//! - [`web`] – URL routes and request handlers+//! - [`smart_http`] – `git clone` support via `git upload-pack`+//! - [`util`] – small helpers (dates, path checks)++pub mod config;+pub mod git;+pub mod highlight;+pub mod markdown;+pub mod pages;+pub mod smart_http;+pub mod util;+pub mod web;
@@ -0,0 +1,62 @@+//! Entry point: read the config, start the web server.+//!+//! Usage: forge [CONFIG.toml] [--listen ADDR]++use forge::config::Config;+use forge::web::{self, AppState};+use std::path::PathBuf;++const USAGE: &str = "usage: forge [CONFIG.toml] [--listen ADDR]++Serves the git repositories listed in CONFIG.toml (default: forge.toml)+as a read-only website, and allows `git clone` over HTTP.";++#[tokio::main]+async fn main() {+ if let Err(e) = run().await {+ eprintln!("forge: {e}");+ std::process::exit(1);+ }+}++async fn run() -> Result<(), String> {+ // Tiny hand-rolled argument parsing; enough for two options.+ let mut config_path = PathBuf::from("forge.toml");+ let mut listen_override = None;+ let mut args = std::env::args().skip(1);+ while let Some(arg) = args.next() {+ match arg.as_str() {+ "-h" | "--help" => {+ println!("{USAGE}");+ return Ok(());+ }+ "--listen" => listen_override = Some(args.next().ok_or("--listen needs an address")?),+ other if other.starts_with('-') => return Err(format!("unknown option {other}\n\n{USAGE}")),+ other => config_path = PathBuf::from(other),+ }+ }++ let mut config = Config::load(&config_path)?;+ if let Some(listen) = listen_override {+ config.server.listen = listen;+ }+ let repos = config.validate()?;+ let listen = config.server.listen.clone();++ let state = AppState::new(config.server, repos);+ let listener = tokio::net::TcpListener::bind(&listen)+ .await+ .map_err(|e| format!("cannot listen on {listen}: {e}"))?;+ eprintln!(+ "forge v{} serving {} repo(s) on http://{listen}/",+ env!("CARGO_PKG_VERSION"),+ state.repos.len()+ );++ axum::serve(listener, web::router(state))+ .with_graceful_shutdown(async {+ let _ = tokio::signal::ctrl_c().await;+ })+ .await+ .map_err(|e| e.to_string())+}
@@ -0,0 +1,105 @@+//! README rendering: Markdown -> HTML, safely.+//!+//! pulldown-cmark normally copies raw HTML from the Markdown straight into the+//! output. A README is untrusted input (anyone can commit one), so we turn raw+//! HTML into plain text and drop `javascript:`-style links.++use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag};++/// Where relative links and images in the README should point.+pub struct LinkBase<'a> {+ /// Prefix for relative links, e.g. "/rust-kernel/tree/"+ pub links: &'a str,+ /// Prefix for relative images, e.g. "/rust-kernel/raw/"+ pub images: &'a str,+}++pub fn render(markdown: &str, base: Option<&LinkBase>) -> String {+ let opts = Options::ENABLE_TABLES+ | Options::ENABLE_STRIKETHROUGH+ | Options::ENABLE_TASKLISTS+ | Options::ENABLE_FOOTNOTES;+ let events = Parser::new_ext(markdown, opts).map(|event| match event {+ // Raw HTML is shown as text instead of being executed by the browser.+ Event::Html(s) | Event::InlineHtml(s) => Event::Text(s),+ Event::Start(Tag::Link { link_type, dest_url, title, id }) => Event::Start(Tag::Link {+ link_type,+ dest_url: fix_url(dest_url, base.map(|b| b.links)),+ title,+ id,+ }),+ Event::Start(Tag::Image { link_type, dest_url, title, id }) => Event::Start(Tag::Image {+ link_type,+ dest_url: fix_url(dest_url, base.map(|b| b.images)),+ title,+ id,+ }),+ other => other,+ });+ let mut out = String::new();+ html::push_html(&mut out, events);+ out+}++/// Keep safe absolute URLs, rewrite relative ones, blank out everything else.+fn fix_url<'a>(url: CowStr<'a>, base: Option<&str>) -> CowStr<'a> {+ let lower = url.trim().to_ascii_lowercase();+ if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:") {+ return url;+ }+ if lower.starts_with('#') || lower.starts_with('/') {+ return url;+ }+ if lower.contains(':') {+ // javascript:, data:, vbscript:, file:, ... -> not allowed+ let before_slash = lower.split('/').next().unwrap_or("");+ if before_slash.contains(':') {+ return CowStr::Borrowed("#");+ }+ }+ match base {+ Some(prefix) => {+ let rel = url.trim_start_matches("./");+ CowStr::from(format!("{prefix}{rel}"))+ }+ None => url,+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn renders_basic_markdown() {+ let html = render("# Title\n\nSome *text*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", None);+ assert!(html.contains("<h1>Title</h1>"));+ assert!(html.contains("<em>text</em>"));+ assert!(html.contains("<table>"));+ }++ #[test]+ fn raw_html_is_escaped() {+ let html = render("hi <script>alert(1)</script>\n\n<div onclick=x>block</div>\n", None);+ assert!(!html.contains("<script>"));+ assert!(html.contains("<script>"));+ assert!(!html.contains("<div"));+ }++ #[test]+ fn dangerous_links_are_removed() {+ let html = render("[x](javascript:alert(1)) [y](JavaScript:alert(1))", None);+ assert!(!html.to_lowercase().contains("javascript:"));+ let html = render("", None);+ assert!(!html.contains("data:"));+ }++ #[test]+ fn relative_links_are_rewritten() {+ let base = LinkBase { links: "/r/tree/", images: "/r/raw/" };+ let html = render("[doc](docs/a.md)  [web](https://e.com)", Some(&base));+ assert!(html.contains("href=\"/r/tree/docs/a.md\""));+ assert!(html.contains("src=\"/r/raw/boot.png\""));+ assert!(html.contains("href=\"https://e.com\""));+ }+}
@@ -0,0 +1,404 @@+//! The HTML for every page, written with `maud`.+//!+//! maud templates are Rust code: `html! { p { (value) } }` produces+//! `<p>value</p>`, and `(value)` is always HTML-escaped. The only places we+//! insert pre-built HTML are wrapped in `PreEscaped(...)`, and those strings+//! come from our Markdown renderer or syntax highlighter, which escape for us.++use maud::{html, Markup, PreEscaped, DOCTYPE};++use crate::git::{CommitDiff, CommitInfo, EntryKind, LineKind, RefInfo, TreeEntry};+use crate::util::{encode_path, encode_query};++/// Which tab is highlighted in the repo menu.+#[derive(Clone, Copy, PartialEq, Eq)]+pub enum Tab {+ Summary,+ Refs,+ Log,+ Tree,+ Commit,+}++/// Information shown at the top of every per-repo page.+pub struct RepoHeader<'a> {+ pub site_title: &'a str,+ pub name: &'a str,+ pub description: &'a str,+ pub tab: Tab,+ /// The `?h=` ref currently being browsed, if the user picked one.+ pub rev: Option<&'a str>,+}++impl RepoHeader<'_> {+ /// "?h=branch" (or "") to keep the selected branch when following links.+ fn h_query(&self) -> String {+ match self.rev {+ Some(r) => format!("?h={}", encode_query(r)),+ None => String::new(),+ }+ }+}++/// The outer HTML shell shared by all pages.+fn layout(title: &str, site_title: &str, body: Markup) -> Markup {+ html! {+ (DOCTYPE)+ html lang="en" {+ head {+ meta charset="utf-8";+ meta name="viewport" content="width=device-width, initial-scale=1";+ meta name="color-scheme" content="light dark";+ title { (title) }+ link rel="stylesheet" href="/static/style.css";+ link rel="stylesheet" href="/static/highlight.css";+ }+ body {+ header.site {+ a.site-title href="/" { (site_title) }+ }+ main { (body) }+ footer { "generated by forge v" (env!("CARGO_PKG_VERSION")) }+ }+ }+ }+}++fn repo_layout(h: &RepoHeader, title: &str, body: Markup) -> Markup {+ let base = format!("/{}", h.name);+ let q = h.h_query();+ let tab = |t: Tab| if h.tab == t { "active" } else { "" };+ let inner = html! {+ div.repo-head {+ h1 { a href={ (base) "/" } { (h.name) } }+ @if !h.description.is_empty() { p.desc { (h.description) } }+ }+ nav.tabs {+ a class=(tab(Tab::Summary)) href={ (base) "/" } { "summary" }+ a class=(tab(Tab::Refs)) href={ (base) "/refs" } { "refs" }+ a class=(tab(Tab::Log)) href={ (base) "/log" (q) } { "log" }+ a class=(tab(Tab::Tree)) href={ (base) "/tree" (q) } { "tree" }+ @if h.tab == Tab::Commit { a.active href="#" { "commit" } }+ @if let Some(r) = h.rev { span.branch { "ref: " code { (r) } } }+ }+ (body)+ };+ layout(&format!("{title} - {}", h.name), h.site_title, inner)+}++// ------------------------------------------------------------------ index --++pub struct IndexRow {+ pub name: String,+ pub description: String,+ pub idle: Option<String>,+}++pub fn index(site_title: &str, rows: &[IndexRow]) -> Markup {+ let body = html! {+ table.list {+ thead { tr { th { "Name" } th { "Description" } th { "Idle" } } }+ tbody {+ @for r in rows {+ tr {+ td { a href={ "/" (r.name) "/" } { (r.name) } }+ td { (r.description) }+ td.age { (r.idle.as_deref().unwrap_or("empty")) }+ }+ }+ @if rows.is_empty() { tr { td colspan="3" { "No repositories configured." } } }+ }+ }+ };+ layout(site_title, site_title, body)+}++// ---------------------------------------------------------------- summary --++fn commit_rows(repo: &str, commits: &[CommitInfo]) -> Markup {+ html! {+ table.list {+ thead { tr { th { "Age" } th { "Commit message" } th { "Author" } th { "Id" } } }+ tbody {+ @for c in commits {+ tr {+ td.age title=(c.date()) { (c.age()) }+ td { a href={ "/" (repo) "/commit/" (c.id) } { (c.summary) } }+ td { (c.author_name) }+ td { code { a href={ "/" (repo) "/commit/" (c.id) } { (c.short_id) } } }+ }+ }+ }+ }+ }+}++fn ref_rows(repo: &str, heading: &str, refs: &[RefInfo]) -> Markup {+ html! {+ table.list {+ thead { tr { th { (heading) } th { "Commit message" } th { "Author" } th { "Age" } } }+ tbody {+ @for r in refs {+ tr {+ td { a href={ "/" (repo) "/log?h=" (encode_query(&r.name)) } { (r.name) } }+ td { a href={ "/" (repo) "/commit/" (r.commit.id) } { (r.commit.summary) } }+ td { (r.commit.author_name) }+ td.age title=(r.commit.date()) { (r.commit.age()) }+ }+ }+ @if refs.is_empty() { tr { td colspan="4" { "none" } } }+ }+ }+ }+}++pub struct Summary<'a> {+ pub clone_url: String,+ pub branches: &'a [RefInfo],+ pub tags: &'a [RefInfo],+ pub commits: &'a [CommitInfo],+ /// (file name, already-rendered safe HTML)+ pub readme: Option<(String, String)>,+}++pub fn summary(h: &RepoHeader, s: &Summary) -> Markup {+ let body = html! {+ div.clone { "clone: " code { "git clone " (s.clone_url) } }+ (ref_rows(h.name, "Branch", s.branches))+ @if !s.tags.is_empty() { (ref_rows(h.name, "Tag", s.tags)) }+ h2 { "Recent commits" }+ (commit_rows(h.name, s.commits))+ @if let Some((name, readme_html)) = &s.readme {+ section.readme {+ h2 { (name) }+ div.markdown { (PreEscaped(readme_html)) }+ }+ }+ };+ repo_layout(h, "summary", body)+}++pub fn refs(h: &RepoHeader, branches: &[RefInfo], tags: &[RefInfo]) -> Markup {+ let body = html! {+ (ref_rows(h.name, "Branch", branches))+ (ref_rows(h.name, "Tag", tags))+ };+ repo_layout(h, "refs", body)+}++// -------------------------------------------------------------------- log --++pub fn log(h: &RepoHeader, commits: &[CommitInfo], offset: usize, page_size: usize, more: bool) -> Markup {+ let base = format!("/{}/log", h.name);+ let h_param = h.rev.map(|r| format!("h={}&", encode_query(r))).unwrap_or_default();+ let body = html! {+ (commit_rows(h.name, commits))+ nav.pager {+ @if offset > 0 {+ a href={ (base) "?" (h_param) "ofs=" (offset.saturating_sub(page_size)) } { "« newer" }+ }+ @if more {+ a href={ (base) "?" (h_param) "ofs=" (offset + page_size) } { "older »" }+ }+ }+ };+ repo_layout(h, "log", body)+}++// ----------------------------------------------------------------- commit --++pub fn commit(h: &RepoHeader, c: &CommitInfo, diff: &CommitDiff) -> Markup {+ let repo = h.name;+ let adds: usize = diff.files.iter().map(|f| f.additions).sum();+ let dels: usize = diff.files.iter().map(|f| f.deletions).sum();+ let body = html! {+ table.commit-info {+ tr { th { "author" } td { (c.author_name) " <" (c.author_email) ">" } td.age { (c.date()) } }+ tr { th { "commit" } td colspan="2" { code { (c.id) } } }+ @for p in &c.parents {+ tr { th { "parent" } td colspan="2" { code { a href={ "/" (repo) "/commit/" (p) } { (p) } } } }+ }+ tr { th { "tree" } td colspan="2" { a href={ "/" (repo) "/tree?h=" (c.id) } { "browse files at this commit" } } }+ }+ pre.commit-msg { (c.message.trim_end()) }+ table.diffstat {+ @for f in &diff.files {+ tr {+ td.status { (f.status) }+ td { a href={ "#" (anchor(&f.new_path)) } { (display_path(f.status, &f.old_path, &f.new_path)) } }+ td.num { span.add-n { "+" (f.additions) } " " span.del-n { "-" (f.deletions) } }+ }+ }+ }+ p.diffsum {+ (plural(diff.files.len(), "file")) " changed, "+ (plural(adds, "insertion")) ", " (plural(dels, "deletion"))+ }+ @for f in &diff.files {+ div.file id=(anchor(&f.new_path)) {+ div.file-head {+ @if f.status == 'D' {+ (f.old_path)+ } @else {+ a href={ "/" (repo) "/blob/" (encode_path(&f.new_path)) "?h=" (c.id) } {+ (display_path(f.status, &f.old_path, &f.new_path))+ }+ }+ }+ @if f.binary {+ p.note { "Binary file changed." }+ } @else {+ pre.diff {+ @for l in &f.lines {+ @match l.kind {+ LineKind::HunkHeader => span.hunk { (l.text) },+ LineKind::Add => span.add { "+" (l.text) },+ LineKind::Remove => span.del { "-" (l.text) },+ LineKind::Context => span.ctx { " " (l.text) },+ }+ }+ }+ }+ }+ }+ @if diff.truncated { p.note { "Diff truncated: this commit is too large to show in full." } }+ };+ repo_layout(h, &format!("commit {}", c.short_id), body)+}++/// "1 file" / "3 files"+fn plural(n: usize, word: &str) -> String {+ if n == 1 {+ format!("1 {word}")+ } else {+ format!("{n} {word}s")+ }+}++fn display_path(status: char, old: &str, new: &str) -> String {+ if status == 'R' && old != new {+ format!("{old} → {new}")+ } else {+ new.to_string()+ }+}++/// HTML id for a file section: "diff-" + the path with odd characters replaced.+fn anchor(path: &str) -> String {+ let safe: String = path.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();+ format!("diff-{safe}")+}++// ------------------------------------------------------------ tree / blob --++/// "repo / src / main.rs" navigation links.+fn breadcrumbs(h: &RepoHeader, path: &str) -> Markup {+ let q = h.h_query();+ // Build ("src", "src"), ("main.rs", "src/main.rs") pairs up front.+ let mut crumbs: Vec<(&str, String)> = Vec::new();+ let mut so_far = String::new();+ for part in path.split('/').filter(|p| !p.is_empty()) {+ if !so_far.is_empty() {+ so_far.push('/');+ }+ so_far.push_str(part);+ crumbs.push((part, so_far.clone()));+ }+ html! {+ div.path {+ a href={ "/" (h.name) "/tree" (q) } { (h.name) }+ @for (name, full) in &crumbs {+ " / "+ a href={ "/" (h.name) "/tree/" (encode_path(full)) (q) } { (name) }+ }+ }+ }+}++fn mode_string(kind: &EntryKind, mode: i32) -> &'static str {+ match kind {+ EntryKind::Dir => "d---------",+ EntryKind::Symlink => "l---------",+ EntryKind::Submodule => "m---------",+ EntryKind::File if mode & 0o111 != 0 => "-rwxr-xr-x",+ EntryKind::File => "-rw-r--r--",+ }+}++pub fn tree(h: &RepoHeader, path: &str, entries: &[TreeEntry]) -> Markup {+ let q = h.h_query();+ let body = html! {+ (breadcrumbs(h, path))+ table.list.tree {+ thead { tr { th { "Mode" } th { "Name" } th.num { "Size" } th {} } }+ tbody {+ @for e in entries {+ tr {+ td.mode { code { (mode_string(&e.kind, e.mode)) } }+ @match e.kind {+ EntryKind::Dir => td { a.dir href={ "/" (h.name) "/tree/" (encode_path(&e.path)) (q) } { (e.name) "/" } },+ EntryKind::Submodule => td { (e.name) " (submodule)" },+ _ => td { a href={ "/" (h.name) "/blob/" (encode_path(&e.path)) (q) } { (e.name) } },+ }+ td.num { @if let Some(s) = e.size { (s) } }+ td { @if e.kind == EntryKind::File {+ a.small href={ "/" (h.name) "/raw/" (encode_path(&e.path)) (q) } { "raw" }+ } }+ }+ }+ }+ }+ };+ repo_layout(h, path, body)+}++pub enum BlobBody {+ /// Highlighted, already-escaped HTML and the number of lines.+ Text {+ html: String,+ lines: usize,+ },+ Binary,+ TooLarge,+}++pub fn blob(h: &RepoHeader, path: &str, size: usize, content: &BlobBody) -> Markup {+ let q = h.h_query();+ let body = html! {+ (breadcrumbs(h, path))+ div.blob-meta {+ (size) " bytes · "+ a href={ "/" (h.name) "/raw/" (encode_path(path)) (q) } { "raw" }+ }+ @match content {+ BlobBody::Binary => p.note { "Binary file, not shown. Use the raw link to download it." },+ BlobBody::TooLarge => p.note { "File too large to display. Use the raw link to download it." },+ BlobBody::Text { html: code, lines } => {+ table.blob {+ tr {+ td.linenos {+ pre {+ @for n in 1..=*lines {+ a id={ "n" (n) } href={ "#n" (n) } { (n) } "\n"+ }+ }+ }+ td.code { pre { (PreEscaped(code)) } }+ }+ }+ }+ }+ };+ repo_layout(h, path, body)+}++// ------------------------------------------------------------------ error --++pub fn error(site_title: &str, status: u16, message: &str) -> Markup {+ let body = html! {+ h1 { (status) }+ p { (message) }+ p { a href="/" { "back to index" } }+ };+ layout(&format!("{status}"), site_title, body)+}
@@ -0,0 +1,209 @@+//! `git clone` over HTTP, using git's "smart" protocol.+//!+//! A clone is two HTTP requests:+//!+//! 1. `GET /{repo}/info/refs?service=git-upload-pack`+//! The server lists its branches/tags ("ref advertisement").+//! 2. `POST /{repo}/git-upload-pack`+//! The client says which commits it wants; the server replies with a+//! packfile containing them.+//!+//! Rather than re-implementing the protocol we run the real+//! `git upload-pack --stateless-rpc` (exactly what git's own+//! `git-http-backend` CGI does) and pipe the HTTP bodies through it.+//! upload-pack is the *read* side of git, so this can never modify a repo.+//! Pushing (`git-receive-pack`) is refused.++use axum::body::{Body, Bytes};+use axum::extract::{Path, Query, State};+use axum::http::{header, HeaderMap, HeaderValue, StatusCode};+use axum::response::{IntoResponse, Response};+use serde::Deserialize;+use std::io::Read;+use std::process::Stdio;+use tokio::io::AsyncWriteExt;+use tokio::process::Command;+use tokio_util::io::ReaderStream;++use crate::web::Shared;++/// Largest request body (the client's "want/have" list) we accept, before and+/// after gzip decompression.+pub const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024;++#[derive(Deserialize)]+pub struct ServiceQuery {+ service: Option<String>,+}++fn plain(status: StatusCode, msg: &str) -> Response {+ (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{msg}\n")).into_response()+}++/// Encode one line in git's "pkt-line" format: 4 hex digits of length+/// (including the 4 digits themselves) followed by the data.+pub fn pkt_line(data: &str) -> String {+ format!("{:04x}{data}", data.len() + 4)+}++/// The client tells us which protocol version it speaks via the `Git-Protocol`+/// header (e.g. "version=2"); upload-pack reads it from the GIT_PROTOCOL env var.+fn git_protocol(headers: &HeaderMap) -> Option<String> {+ let v = headers.get("git-protocol")?.to_str().ok()?;+ let ok = v.len() < 256 && v.chars().all(|c| c.is_ascii_alphanumeric() || "=:._-".contains(c));+ ok.then(|| v.to_string())+}++/// Step 1: `GET /{repo}/info/refs?service=git-upload-pack`+pub async fn info_refs(+ State(st): State<Shared>,+ Path(repo): Path<String>,+ Query(q): Query<ServiceQuery>,+ headers: HeaderMap,+) -> Response {+ let Ok(rc) = st.repo(&repo) else {+ return plain(StatusCode::NOT_FOUND, "repository not found");+ };+ match q.service.as_deref() {+ Some("git-upload-pack") => {}+ Some("git-receive-pack") => return plain(StatusCode::FORBIDDEN, "push is not supported"),+ _ => {+ return plain(+ StatusCode::FORBIDDEN,+ "only the smart HTTP protocol is supported (use git >= 1.6.6)",+ )+ }+ }++ let protocol = git_protocol(&headers);+ let mut cmd = Command::new("git");+ cmd.args(["upload-pack", "--stateless-rpc", "--advertise-refs"])+ .arg(&rc.path)+ .stdin(Stdio::null())+ .kill_on_drop(true);+ if let Some(p) = &protocol {+ cmd.env("GIT_PROTOCOL", p);+ }+ let out = match cmd.output().await {+ Ok(o) if o.status.success() => o,+ Ok(o) => {+ eprintln!("upload-pack --advertise-refs failed: {}", String::from_utf8_lossy(&o.stderr));+ return plain(StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed");+ }+ Err(e) => {+ eprintln!("could not run git: {e}");+ return plain(StatusCode::INTERNAL_SERVER_ERROR, "could not run git");+ }+ };++ // Protocol v0/v1 responses start with a "# service=..." banner and a flush+ // packet; protocol v2 responses don't (same as git-http-backend).+ let mut body = Vec::new();+ let is_v2 = protocol.as_deref().is_some_and(|p| p.contains("version=2"));+ if !is_v2 {+ body.extend_from_slice(pkt_line("# service=git-upload-pack\n").as_bytes());+ body.extend_from_slice(b"0000");+ }+ body.extend_from_slice(&out.stdout);++ let mut res = (StatusCode::OK, body).into_response();+ let h = res.headers_mut();+ h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-git-upload-pack-advertisement"));+ h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));+ res+}++/// Step 2: `POST /{repo}/git-upload-pack` – stream a packfile back.+pub async fn upload_pack(+ State(st): State<Shared>,+ Path(repo): Path<String>,+ headers: HeaderMap,+ body: Bytes,+) -> Response {+ let Ok(rc) = st.repo(&repo) else {+ return plain(StatusCode::NOT_FOUND, "repository not found");+ };+ let ctype = headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok());+ if ctype != Some("application/x-git-upload-pack-request") {+ return plain(StatusCode::UNSUPPORTED_MEDIA_TYPE, "expected application/x-git-upload-pack-request");+ }++ // git gzips large requests.+ let gzipped = headers+ .get(header::CONTENT_ENCODING)+ .and_then(|v| v.to_str().ok())+ .is_some_and(|v| v.eq_ignore_ascii_case("gzip") || v.eq_ignore_ascii_case("x-gzip"));+ let input: Vec<u8> = if gzipped {+ let mut out = Vec::new();+ let decoder = flate2::read::GzDecoder::new(&body[..]);+ if decoder.take(MAX_REQUEST_BYTES as u64 + 1).read_to_end(&mut out).is_err()+ || out.len() > MAX_REQUEST_BYTES+ {+ return plain(StatusCode::BAD_REQUEST, "bad or oversized gzip body");+ }+ out+ } else {+ body.to_vec()+ };++ let mut cmd = Command::new("git");+ cmd.args(["upload-pack", "--stateless-rpc"])+ .arg(&rc.path)+ .stdin(Stdio::piped())+ .stdout(Stdio::piped())+ .stderr(Stdio::inherit())+ // If the client disconnects, the response body (and with it the+ // child's stdout) is dropped; kill_on_drop makes sure git exits too.+ .kill_on_drop(true);+ if let Some(p) = git_protocol(&headers) {+ cmd.env("GIT_PROTOCOL", p);+ }+ let mut child = match cmd.spawn() {+ Ok(c) => c,+ Err(e) => {+ eprintln!("could not run git: {e}");+ return plain(StatusCode::INTERNAL_SERVER_ERROR, "could not run git");+ }+ };+ let mut stdin = child.stdin.take().expect("stdin was piped");+ let stdout = child.stdout.take().expect("stdout was piped");++ // Feed the request to git in the background, then wait for it to exit,+ // while we stream its output to the client.+ tokio::spawn(async move {+ let _ = stdin.write_all(&input).await;+ drop(stdin); // closing stdin tells git the request is complete+ let _ = child.wait().await;+ });++ let mut res = Response::new(Body::from_stream(ReaderStream::new(stdout)));+ let h = res.headers_mut();+ h.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-git-upload-pack-result"));+ h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));+ res+}++/// `POST /{repo}/git-receive-pack` – pushing is not supported in v0.1.+pub async fn receive_pack_refused() -> Response {+ plain(StatusCode::FORBIDDEN, "push is not supported")+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn pkt_line_format() {+ assert_eq!(pkt_line("# service=git-upload-pack\n"), "001e# service=git-upload-pack\n");+ assert_eq!(pkt_line(""), "0004");+ }++ #[test]+ fn protocol_header_is_filtered() {+ let mut h = HeaderMap::new();+ h.insert("git-protocol", HeaderValue::from_static("version=2"));+ assert_eq!(git_protocol(&h).as_deref(), Some("version=2"));+ h.insert("git-protocol", HeaderValue::from_static("version=2; rm -rf /"));+ assert_eq!(git_protocol(&h), None);+ }+}
@@ -0,0 +1,183 @@+//! Small helpers that don't depend on git or the web server.++/// Turn "seconds ago" into a short human string like "3 hours ago".+pub fn age(seconds_ago: i64) -> String {+ if seconds_ago < 0 {+ return "in the future".to_string();+ }+ let s = seconds_ago;+ let (n, unit) = if s < 60 {+ return "just now".to_string();+ } else if s < 3600 {+ (s / 60, "minute")+ } else if s < 86_400 {+ (s / 3600, "hour")+ } else if s < 86_400 * 30 {+ (s / 86_400, "day")+ } else if s < 86_400 * 365 {+ (s / (86_400 * 30), "month")+ } else {+ (s / (86_400 * 365), "year")+ };+ let plural = if n == 1 { "" } else { "s" };+ format!("{n} {unit}{plural} ago")+}++/// Seconds since the Unix epoch, right now.+pub fn now_unix() -> i64 {+ std::time::SystemTime::now()+ .duration_since(std::time::UNIX_EPOCH)+ .map(|d| d.as_secs() as i64)+ .unwrap_or(0)+}++/// Format a Unix timestamp + UTC offset (in minutes) as "2026-10-31 15:04:05 -0700".+///+/// Implemented by hand (days-to-civil-date algorithm) to avoid pulling in a+/// date/time crate for one function.+pub fn format_time(unix: i64, offset_minutes: i32) -> String {+ let local = unix + offset_minutes as i64 * 60;+ let days = local.div_euclid(86_400);+ let secs = local.rem_euclid(86_400);+ let (y, m, d) = civil_from_days(days);+ let sign = if offset_minutes < 0 { '-' } else { '+' };+ let off = offset_minutes.abs();+ format!(+ "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} {sign}{:02}{:02}",+ secs / 3600,+ (secs / 60) % 60,+ secs % 60,+ off / 60,+ off % 60+ )+}++/// Howard Hinnant's algorithm: days since 1970-01-01 -> (year, month, day).+fn civil_from_days(z: i64) -> (i64, u32, u32) {+ let z = z + 719_468;+ let era = z.div_euclid(146_097);+ let doe = z.rem_euclid(146_097);+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;+ let y = yoe + era * 400;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+ let mp = (5 * doy + 2) / 153;+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;+ let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;+ (if m <= 2 { y + 1 } else { y }, m, d)+}++/// Check a path that came from a URL, like `src/main.rs`.+///+/// We never touch the filesystem with these paths (files are looked up inside+/// git objects), but we still refuse anything odd so that `..`, absolute paths+/// and control characters can't sneak through. Returns the cleaned path with+/// no leading or trailing slashes ("" means the repo root).+pub fn clean_repo_path(raw: &str) -> Option<String> {+ let mut parts = Vec::new();+ for part in raw.split('/') {+ match part {+ "" => continue, // tolerate "a//b" and leading/trailing slashes+ "." | ".." => return None,+ p if p.chars().any(|c| c.is_control() || c == '\\') => return None,+ p => parts.push(p),+ }+ }+ Some(parts.join("/"))+}++/// Only allow ref names / commit ids made of "normal" characters. This keeps+/// out git revision syntax such as `HEAD@{1}`, `main^`, `:/text` or `a..b`.+pub fn is_safe_ref(name: &str) -> bool {+ !name.is_empty()+ && name.len() <= 200+ && !name.starts_with('-')+ && !name.contains("..")+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn ages() {+ assert_eq!(age(5), "just now");+ assert_eq!(age(60), "1 minute ago");+ assert_eq!(age(7200), "2 hours ago");+ assert_eq!(age(86_400 * 3), "3 days ago");+ assert_eq!(age(86_400 * 65), "2 months ago");+ assert_eq!(age(86_400 * 800), "2 years ago");+ assert_eq!(age(-10), "in the future");+ }++ #[test]+ fn formats_time() {+ assert_eq!(format_time(0, 0), "1970-01-01 00:00:00 +0000");+ // 2026-10-31 22:31:00 UTC shown in UTC-7+ assert_eq!(format_time(1_793_485_860, -420), "2026-10-31 15:31:00 -0700");+ assert_eq!(format_time(951_782_400, 330), "2000-02-29 05:30:00 +0530");+ }++ #[test]+ fn repo_paths() {+ assert_eq!(clean_repo_path("src/main.rs").as_deref(), Some("src/main.rs"));+ assert_eq!(clean_repo_path("/src//main.rs/").as_deref(), Some("src/main.rs"));+ assert_eq!(clean_repo_path("").as_deref(), Some(""));+ assert_eq!(clean_repo_path("../etc/passwd"), None);+ assert_eq!(clean_repo_path("src/../../x"), None);+ assert_eq!(clean_repo_path("./x"), None);+ assert_eq!(clean_repo_path("a\\..\\b"), None);+ assert_eq!(clean_repo_path("a\0b"), None);+ }++ #[test]+ fn refs() {+ assert!(is_safe_ref("main"));+ assert!(is_safe_ref("edward/syscalls"));+ assert!(is_safe_ref("v0.15.1"));+ assert!(is_safe_ref("58fc4fd"));+ assert!(!is_safe_ref("HEAD@{1}"));+ assert!(!is_safe_ref("main^"));+ assert!(!is_safe_ref("a..b"));+ assert!(!is_safe_ref("--upload-pack=x"));+ assert!(!is_safe_ref(""));+ }+}++/// Percent-encode a repo path for use inside a URL path (keeps `/`).+/// File names can contain spaces, `#`, `?` and other characters that would+/// otherwise break links.+pub fn encode_path(path: &str) -> String {+ encode(path, true)+}++/// Percent-encode a value for use in a `?key=value` query string.+pub fn encode_query(value: &str) -> String {+ encode(value, false)+}++fn encode(s: &str, keep_slash: bool) -> String {+ let mut out = String::with_capacity(s.len());+ for &b in s.as_bytes() {+ let keep =+ b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') || (keep_slash && b == b'/');+ if keep {+ out.push(b as char);+ } else {+ out.push_str(&format!("%{b:02X}"));+ }+ }+ out+}++#[cfg(test)]+mod encode_tests {+ use super::*;++ #[test]+ fn encodes() {+ assert_eq!(encode_path("src/a b#c?.rs"), "src/a%20b%23c%3F.rs");+ assert_eq!(encode_query("edward/syscalls"), "edward%2Fsyscalls");+ assert_eq!(encode_path("ünï"), "%C3%BCn%C3%AF");+ }+}
@@ -0,0 +1,404 @@+//! URL routes and the handler function for each page.+//!+//! URL layout (similar to cgit):+//!+//! | URL | page |+//! |--------------------------------------|-------------------------------|+//! | `/` | list of repos |+//! | `/{repo}/` | summary |+//! | `/{repo}/refs` | all branches and tags |+//! | `/{repo}/log?h=branch&ofs=50` | commit log, 50 per page |+//! | `/{repo}/commit/{id}` | one commit with its diff |+//! | `/{repo}/tree/{path}?h=branch` | directory listing |+//! | `/{repo}/blob/{path}?h=branch` | one file, highlighted |+//! | `/{repo}/raw/{path}?h=branch` | one file, raw bytes |+//! | `/{repo}/info/refs`, `/git-upload-pack` | `git clone` (see smart_http) |+//!+//! `?h=` picks a branch, tag or commit id; without it we use HEAD.++use axum::extract::{DefaultBodyLimit, Path, Query, State};+use axum::http::{header, HeaderMap, HeaderValue, StatusCode};+use axum::response::{Html, IntoResponse, Redirect, Response};+use axum::routing::{get, post};+use axum::{middleware, Router};+use serde::Deserialize;+use std::sync::Arc;++use crate::config::{RepoConfig, Repos, ServerConfig};+use crate::git::{self, PathContent};+use crate::highlight::Highlighter;+use crate::markdown::{self, LinkBase};+use crate::pages::{self, BlobBody, IndexRow, RepoHeader, Tab};+use crate::{smart_http, util};++/// Commits per page on the log view.+pub const LOG_PAGE_SIZE: usize = 50;+/// Files larger than this are not rendered in the blob view.+const MAX_BLOB_DISPLAY: usize = 2 * 1024 * 1024;++const STYLE_CSS: &str = include_str!("../static/style.css");++/// Shared, read-only state handed to every request.+pub struct AppState {+ pub server: ServerConfig,+ pub repos: Repos,+ pub highlighter: Highlighter,+}++pub type Shared = Arc<AppState>;++impl AppState {+ pub fn new(server: ServerConfig, repos: Repos) -> Shared {+ Arc::new(AppState { server, repos, highlighter: Highlighter::new() })+ }++ /// Look up a repo by its URL name. Unknown names are a 404: this is the+ /// check that keeps the server from reading anything that isn't configured.+ pub fn repo(&self, name: &str) -> Result<RepoConfig, AppError> {+ self.repos.get(name).cloned().ok_or_else(|| AppError::not_found("no such repository"))+ }++ /// The URL people should pass to `git clone`.+ fn clone_url(&self, repo: &str, headers: &HeaderMap) -> String {+ let base = match &self.server.clone_base_url {+ Some(b) => b.trim_end_matches('/').to_string(),+ None => {+ let host = headers+ .get(header::HOST)+ .and_then(|h| h.to_str().ok())+ .filter(|h| h.chars().all(|c| c.is_ascii_alphanumeric() || ".:-[]".contains(c)))+ .unwrap_or(&self.server.listen);+ format!("http://{host}")+ }+ };+ format!("{base}/{repo}")+ }+}++pub fn router(state: Shared) -> Router {+ Router::new()+ .route("/", get(index))+ .route("/static/style.css", get(style_css))+ .route("/static/highlight.css", get(highlight_css))+ .route("/{repo}", get(add_slash))+ .route("/{repo}/", get(summary))+ .route("/{repo}/refs", get(refs))+ .route("/{repo}/log", get(log))+ .route("/{repo}/commit/{id}", get(commit))+ .route("/{repo}/tree", get(tree_root))+ .route("/{repo}/tree/", get(tree_root))+ .route("/{repo}/tree/{*path}", get(tree))+ .route("/{repo}/blob/{*path}", get(blob))+ .route("/{repo}/raw/{*path}", get(raw))+ // git clone over smart HTTP (read-only)+ .route("/{repo}/info/refs", get(smart_http::info_refs))+ .route(+ "/{repo}/git-upload-pack",+ post(smart_http::upload_pack).layer(DefaultBodyLimit::max(smart_http::MAX_REQUEST_BYTES)),+ )+ .route("/{repo}/git-receive-pack", post(smart_http::receive_pack_refused))+ .fallback(|| async { AppError::not_found("page not found") })+ .layer(middleware::map_response(security_headers))+ .with_state(state)+}++/// Extra headers on every response. The Content-Security-Policy tells the+/// browser to never run scripts on our pages, as a second line of defence+/// behind HTML escaping.+async fn security_headers(mut res: Response) -> Response {+ let h = res.headers_mut();+ h.insert(+ header::CONTENT_SECURITY_POLICY,+ HeaderValue::from_static("default-src 'none'; style-src 'self'; img-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"),+ );+ h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));+ h.insert(header::REFERRER_POLICY, HeaderValue::from_static("same-origin"));+ res+}++// ------------------------------------------------------------ errors --++/// An error page. Handlers return `Result<_, AppError>` and `?` does the rest.+#[derive(Debug)]+pub struct AppError {+ pub status: StatusCode,+ pub message: String,+}++impl AppError {+ pub fn not_found(msg: &str) -> AppError {+ AppError { status: StatusCode::NOT_FOUND, message: msg.to_string() }+ }+}++impl From<git::Error> for AppError {+ fn from(e: git::Error) -> Self {+ match e {+ git::Error::NotFound(m) => AppError { status: StatusCode::NOT_FOUND, message: m },+ git::Error::Git(e) => {+ eprintln!("git error: {e}");+ AppError {+ status: StatusCode::INTERNAL_SERVER_ERROR,+ message: "internal error reading repository".into(),+ }+ }+ }+ }+}++impl IntoResponse for AppError {+ fn into_response(self) -> Response {+ let page = pages::error("forge", self.status.as_u16(), &self.message);+ (self.status, Html(page.into_string())).into_response()+ }+}++/// libgit2 calls block the thread, so run them on tokio's blocking pool+/// instead of stalling the async web server.+async fn blocking<T: Send + 'static>(+ f: impl FnOnce() -> Result<T, AppError> + Send + 'static,+) -> Result<T, AppError> {+ tokio::task::spawn_blocking(f).await.map_err(|_| AppError {+ status: StatusCode::INTERNAL_SERVER_ERROR,+ message: "internal error".into(),+ })?+}++#[derive(Deserialize, Default)]+pub struct RevQuery {+ /// branch / tag / commit id+ pub h: Option<String>,+ /// log offset+ pub ofs: Option<usize>,+}++impl RevQuery {+ fn rev(&self) -> Option<&str> {+ self.h.as_deref().filter(|s| !s.is_empty())+ }+}++// ---------------------------------------------------------- handlers --++async fn style_css() -> impl IntoResponse {+ ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], STYLE_CSS)+}++async fn highlight_css(State(st): State<Shared>) -> impl IntoResponse {+ ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], st.highlighter.css.clone())+}++async fn add_slash(State(st): State<Shared>, Path(repo): Path<String>) -> Result<Redirect, AppError> {+ let rc = st.repo(&repo)?;+ Ok(Redirect::permanent(&format!("/{}/", rc.name)))+}++async fn index(State(st): State<Shared>) -> Result<Html<String>, AppError> {+ blocking(move || {+ let now = util::now_unix();+ let rows: Vec<IndexRow> = st+ .repos+ .iter()+ .map(|rc| IndexRow {+ name: rc.name.clone(),+ description: rc.description.clone(),+ idle: git::open(&rc.path).ok().and_then(|r| git::last_change(&r)).map(|t| util::age(now - t)),+ })+ .collect();+ Ok(Html(pages::index(&st.server.site_title, &rows).into_string()))+ })+ .await+}++fn header<'a>(st: &'a AppState, rc: &'a RepoConfig, tab: Tab, rev: Option<&'a str>) -> RepoHeader<'a> {+ RepoHeader { site_title: &st.server.site_title, name: &rc.name, description: &rc.description, tab, rev }+}++async fn summary(+ State(st): State<Shared>,+ Path(repo): Path<String>,+ headers: HeaderMap,+) -> Result<Html<String>, AppError> {+ let rc = st.repo(&repo)?;+ let clone_url = st.clone_url(&rc.name, &headers);+ blocking(move || {+ let r = git::open(&rc.path)?;+ let mut branches = git::branches(&r)?;+ let mut tags = git::tags(&r)?;+ branches.truncate(10);+ tags.truncate(10);+ let (commits, readme) = match git::resolve(&r, None) {+ Ok(head) => {+ let (commits, _) = git::log(&r, head, 0, 10)?;+ let base_links = format!("/{}/tree/", rc.name);+ let base_images = format!("/{}/raw/", rc.name);+ let base = LinkBase { links: &base_links, images: &base_images };+ let readme = git::readme(&r, head).map(|(name, text)| {+ let html = if name.ends_with(".md") || name.ends_with(".markdown") {+ markdown::render(&text, Some(&base))+ } else {+ format!("<pre>{}</pre>", crate::highlight::escape(&text))+ };+ (name, html)+ });+ (commits, readme)+ }+ Err(_) => (Vec::new(), None), // empty repo+ };+ let s = pages::Summary { clone_url, branches: &branches, tags: &tags, commits: &commits, readme };+ Ok(Html(pages::summary(&header(&st, &rc, Tab::Summary, None), &s).into_string()))+ })+ .await+}++async fn refs(State(st): State<Shared>, Path(repo): Path<String>) -> Result<Html<String>, AppError> {+ let rc = st.repo(&repo)?;+ blocking(move || {+ let r = git::open(&rc.path)?;+ let page = pages::refs(&header(&st, &rc, Tab::Refs, None), &git::branches(&r)?, &git::tags(&r)?);+ Ok(Html(page.into_string()))+ })+ .await+}++async fn log(+ State(st): State<Shared>,+ Path(repo): Path<String>,+ Query(q): Query<RevQuery>,+) -> Result<Html<String>, AppError> {+ let rc = st.repo(&repo)?;+ blocking(move || {+ let r = git::open(&rc.path)?;+ let start = git::resolve(&r, q.rev())?;+ let ofs = q.ofs.unwrap_or(0);+ let (commits, more) = git::log(&r, start, ofs, LOG_PAGE_SIZE)?;+ let page = pages::log(&header(&st, &rc, Tab::Log, q.rev()), &commits, ofs, LOG_PAGE_SIZE, more);+ Ok(Html(page.into_string()))+ })+ .await+}++async fn commit(+ State(st): State<Shared>,+ Path((repo, id)): Path<(String, String)>,+) -> Result<Html<String>, AppError> {+ let rc = st.repo(&repo)?;+ blocking(move || {+ let r = git::open(&rc.path)?;+ let oid = git::resolve(&r, Some(&id))?;+ let info = git::commit(&r, oid)?;+ let diff = git::commit_diff(&r, oid)?;+ Ok(Html(pages::commit(&header(&st, &rc, Tab::Commit, None), &info, &diff).into_string()))+ })+ .await+}++async fn tree_root(+ State(st): State<Shared>,+ Path(repo): Path<String>,+ Query(q): Query<RevQuery>,+) -> Result<Response, AppError> {+ show_path(st, repo, String::new(), q).await+}++async fn tree(+ State(st): State<Shared>,+ Path((repo, path)): Path<(String, String)>,+ Query(q): Query<RevQuery>,+) -> Result<Response, AppError> {+ show_path(st, repo, path, q).await+}++async fn blob(+ State(st): State<Shared>,+ Path((repo, path)): Path<(String, String)>,+ Query(q): Query<RevQuery>,+) -> Result<Response, AppError> {+ show_path(st, repo, path, q).await+}++/// Shared by tree and blob: a directory shows a listing, a file shows its+/// contents, whichever URL was used.+async fn show_path(st: Shared, repo: String, raw_path: String, q: RevQuery) -> Result<Response, AppError> {+ let rc = st.repo(&repo)?;+ let path = util::clean_repo_path(&raw_path).ok_or_else(|| AppError::not_found("invalid path"))?;+ blocking(move || {+ let r = git::open(&rc.path)?;+ let commit = git::resolve(&r, q.rev())?;+ let content = git::path_content(&r, commit, &path)?;+ let page = match content {+ PathContent::Dir(entries) => pages::tree(&header(&st, &rc, Tab::Tree, q.rev()), &path, &entries),+ PathContent::File { data, .. } => {+ let file_name = path.rsplit('/').next().unwrap_or(&path);+ let body = if data.len() > MAX_BLOB_DISPLAY {+ BlobBody::TooLarge+ } else if git::is_binary(&data) {+ BlobBody::Binary+ } else {+ let text = String::from_utf8_lossy(&data);+ BlobBody::Text {+ html: st.highlighter.highlight(file_name, &text),+ lines: text.lines().count(),+ }+ };+ pages::blob(&header(&st, &rc, Tab::Tree, q.rev()), &path, data.len(), &body)+ }+ };+ Ok(Html(page.into_string()).into_response())+ })+ .await+}++async fn raw(+ State(st): State<Shared>,+ Path((repo, path)): Path<(String, String)>,+ Query(q): Query<RevQuery>,+) -> Result<Response, AppError> {+ let rc = st.repo(&repo)?;+ let path = util::clean_repo_path(&path).ok_or_else(|| AppError::not_found("invalid path"))?;+ blocking(move || {+ let r = git::open(&rc.path)?;+ let commit = git::resolve(&r, q.rev())?;+ match git::path_content(&r, commit, &path)? {+ PathContent::Dir(_) => Err(AppError::not_found("that path is a directory")),+ PathContent::File { data, .. } => {+ let ctype = raw_content_type(&path, &data);+ let mut res = (StatusCode::OK, data).into_response();+ res.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));+ if ctype == "application/octet-stream" {+ res.headers_mut()+ .insert(header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment"));+ }+ Ok(res)+ }+ }+ })+ .await+}++/// Raw files are served as plain text, a few safe image types, or a download.+/// Never as text/html or SVG, which a browser would execute.+pub fn raw_content_type(path: &str, data: &[u8]) -> &'static str {+ let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();+ match ext.as_str() {+ "png" => "image/png",+ "jpg" | "jpeg" => "image/jpeg",+ "gif" => "image/gif",+ "webp" => "image/webp",+ _ if !git::is_binary(data) && std::str::from_utf8(data).is_ok() => "text/plain; charset=utf-8",+ _ => "application/octet-stream",+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn raw_types() {+ assert_eq!(raw_content_type("a.png", b"\x89PNG\0"), "image/png");+ assert_eq!(raw_content_type("index.html", b"<script>"), "text/plain; charset=utf-8");+ assert_eq!(raw_content_type("x.svg", b"<svg onload=x>"), "text/plain; charset=utf-8");+ assert_eq!(raw_content_type("bin", b"\0\x01"), "application/octet-stream");+ }+}
@@ -0,0 +1,80 @@+/* forge: plain, cgit-like styling. Light by default, dark when the OS asks. */+:root {+ --bg: #ffffff; --fg: #1f2328; --muted: #6a737d; --link: #0645ad;+ --border: #d0d7de; --head: #f6f8fa; --hover: #f3f6fa;+ --add-bg: #e6ffec; --add-fg: #116329; --del-bg: #ffebe9; --del-fg: #a40e26;+ --hunk: #0550ae; --hunk-bg: #ddf4ff; --code-bg: #f6f8fa;+}+@media (prefers-color-scheme: dark) {+ :root {+ --bg: #0d1117; --fg: #d1d7e0; --muted: #8b949e; --link: #6cb6ff;+ --border: #30363d; --head: #161b22; --hover: #1c2129;+ --add-bg: #12261e; --add-fg: #6fdd8b; --del-bg: #2d1316; --del-fg: #ff9492;+ --hunk: #79c0ff; --hunk-bg: #121d2f; --code-bg: #161b22;+ }+}+* { box-sizing: border-box; }+body { margin: 0 auto; max-width: 1200px; padding: 0 1em; background: var(--bg); color: var(--fg);+ font: 14px/1.45 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; }+a { color: var(--link); text-decoration: none; }+a:hover { text-decoration: underline; }+code, pre, .mode, .age, .num { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13px; }+header.site { padding: 0.8em 0; border-bottom: 1px solid var(--border); }+.site-title { font-size: 1.4em; font-weight: bold; color: var(--fg); }+footer { margin: 2em 0 1em; padding-top: 0.5em; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }+h1 { font-size: 1.5em; margin: 0.6em 0 0.1em; }+h1 a { color: var(--fg); }+h2 { font-size: 1.15em; margin: 1.4em 0 0.5em; }+.desc { color: var(--muted); margin: 0 0 0.6em; }+nav.tabs { border-bottom: 2px solid var(--border); margin-bottom: 1em; display: flex; gap: 0.2em; align-items: end; flex-wrap: wrap; }+nav.tabs a { padding: 0.35em 0.9em; border: 1px solid transparent; border-bottom: none; color: var(--fg); }+nav.tabs a.active { border-color: var(--border); background: var(--head); font-weight: bold; }+nav.tabs .branch { margin-left: auto; color: var(--muted); padding-bottom: 0.3em; }+table { border-collapse: collapse; }+table.list { width: 100%; margin-bottom: 1.2em; }+table.list th { text-align: left; background: var(--head); border-bottom: 1px solid var(--border); padding: 0.3em 0.6em; }+table.list td { padding: 0.2em 0.6em; border-bottom: 1px solid var(--border); vertical-align: top; }+table.list tbody tr:hover { background: var(--hover); }+td.age, td.num, th.num { white-space: nowrap; color: var(--muted); }+td.num, th.num { text-align: right; }+.clone { margin: 0 0 1em; color: var(--muted); }+.clone code { color: var(--fg); background: var(--code-bg); padding: 0.15em 0.4em; border: 1px solid var(--border); }+.pager { display: flex; gap: 1.5em; justify-content: center; margin: 1em 0; }+.path { margin: 0 0 0.8em; font-weight: bold; }+.small { font-size: 12px; }+.note { color: var(--muted); font-style: italic; }+/* commit */+table.commit-info th { text-align: right; padding: 0.1em 0.8em 0.1em 0; color: var(--muted); font-weight: normal; vertical-align: top; }+table.commit-info td { padding: 0.1em 0.8em 0.1em 0; }+pre.commit-msg { background: var(--code-bg); border: 1px solid var(--border); padding: 0.8em; white-space: pre-wrap; }+table.diffstat td { padding: 0.05em 0.8em 0.05em 0; }+td.status { font-family: monospace; color: var(--muted); }+.add-n { color: var(--add-fg); } .del-n { color: var(--del-fg); }+.diffsum { color: var(--muted); }+.file { border: 1px solid var(--border); margin: 1em 0; }+.file-head { background: var(--head); padding: 0.4em 0.6em; border-bottom: 1px solid var(--border); font-family: monospace; font-weight: bold; }+pre.diff { margin: 0; overflow-x: auto; }+pre.diff span { display: block; padding: 0 0.6em; white-space: pre; min-height: 1.45em; }+pre.diff .add { background: var(--add-bg); color: var(--add-fg); }+pre.diff .del { background: var(--del-bg); color: var(--del-fg); }+pre.diff .hunk { background: var(--hunk-bg); color: var(--hunk); }+/* blob */+.blob-meta { color: var(--muted); margin-bottom: 0.5em; }+table.blob { width: 100%; border: 1px solid var(--border); table-layout: fixed; }+table.blob td { vertical-align: top; padding: 0; }+td.linenos { width: 4.5em; background: var(--head); border-right: 1px solid var(--border); text-align: right; }+td.linenos pre { margin: 0; padding: 0.5em 0.6em; }+td.linenos a { color: var(--muted); }+td.code { overflow-x: auto; }+td.code pre { margin: 0; padding: 0.5em 0.8em; background: transparent; }+pre span[class^="hl-"] { background: transparent !important; }+:target { background: var(--hunk-bg); }+/* README */+.readme { border-top: 1px solid var(--border); margin-top: 1.5em; }+.markdown { max-width: 900px; }+.markdown pre { background: var(--code-bg); border: 1px solid var(--border); padding: 0.8em; overflow-x: auto; }+.markdown code { background: var(--code-bg); padding: 0.1em 0.3em; }+.markdown pre code { background: none; padding: 0; }+.markdown table td, .markdown table th { border: 1px solid var(--border); padding: 0.25em 0.6em; }+.markdown img { max-width: 100%; }+.markdown blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid var(--border); color: var(--muted); }
@@ -0,0 +1,275 @@+//! End-to-end tests: build real git repositories in a temp dir, then check+//! the HTML pages and do a real `git clone` against a running server.++use axum::body::Body;+use axum::http::{Request, StatusCode};+use forge::config::Config;+use forge::web::{router, AppState};+use http_body_util::BodyExt;+use std::path::Path;+use std::process::Command;+use tower::ServiceExt; // for `oneshot`++/// Run a git command in `dir`, panicking with its output if it fails.+fn git(dir: &Path, args: &[&str]) -> String {+ let out = Command::new("git")+ .args([+ "-c",+ "user.name=Test User",+ "-c",+ "user.email=test@example.com",+ "-c",+ "init.defaultBranch=main",+ ])+ .args(args)+ .current_dir(dir)+ .env("GIT_CONFIG_NOSYSTEM", "1")+ .env("GIT_AUTHOR_DATE", "2026-01-01T12:00:00Z")+ .env("GIT_COMMITTER_DATE", "2026-01-01T12:00:00Z")+ .output()+ .expect("git is installed");+ assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));+ String::from_utf8_lossy(&out.stdout).into_owned()+}++/// A temp dir holding: `work/` (normal repo, 56 commits) and `bare.git`.+struct Fixture {+ dir: tempfile::TempDir,+ head: String,+ change_commit: String,+}++fn fixture() -> Fixture {+ let dir = tempfile::tempdir().unwrap();+ let work = dir.path().join("work");+ std::fs::create_dir(&work).unwrap();+ git(&work, &["init", "-q"]);++ std::fs::write(+ work.join("README.md"),+ "# Hello Forge\n\nSome **bold** text.\n\n<script>alert('readme')</script>\n",+ )+ .unwrap();+ std::fs::create_dir(work.join("src")).unwrap();+ std::fs::write(work.join("src/main.rs"), "fn main() {\n println!(\"old line\");\n}\n").unwrap();+ std::fs::write(work.join("src/odd name #1.txt"), "spaces & hashes <ok>\n").unwrap();+ git(&work, &["add", "."]);+ git(&work, &["commit", "-q", "-m", "initial commit"]);++ // 54 filler commits so the log has more than one page (50 per page).+ for i in 0..54 {+ git(&work, &["commit", "-q", "--allow-empty", "-m", &format!("filler {i}")]);+ }++ std::fs::write(work.join("src/main.rs"), "fn main() {\n println!(\"new line\");\n}\n").unwrap();+ git(&work, &["commit", "-q", "-am", "change <greeting>"]);+ let change_commit = git(&work, &["rev-parse", "HEAD"]).trim().to_string();+ git(&work, &["tag", "v1.0"]);+ git(&work, &["branch", "feature/x"]);+ let head = change_commit.clone();++ git(dir.path(), &["clone", "-q", "--bare", "work", "bare.git"]);+ Fixture { dir, head, change_commit }+}++fn state_for(f: &Fixture) -> forge::web::Shared {+ let toml = format!(+ r#"+ [server]+ site_title = "test forge"+ clone_base_url = "http://example.test"++ [[repo]]+ name = "work"+ path = "{}"+ description = "non-bare <b>repo</b>"++ [[repo]]+ name = "bare"+ path = "{}"+ description = "a bare repo"+ "#,+ f.dir.path().join("work").display(),+ f.dir.path().join("bare.git").display(),+ );+ let cfg = Config::from_toml(&toml).unwrap();+ let repos = cfg.validate().unwrap();+ AppState::new(cfg.server, repos)+}++/// Make one request against the router (no network) and return status + body.+async fn get(f: &Fixture, uri: &str) -> (StatusCode, String) {+ let app = router(state_for(f));+ let res = app.oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();+ let status = res.status();+ let bytes = res.into_body().collect().await.unwrap().to_bytes();+ (status, String::from_utf8_lossy(&bytes).into_owned())+}++#[tokio::test]+async fn index_lists_repos_and_escapes_descriptions() {+ let f = fixture();+ let (status, body) = get(&f, "/").await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("href=\"/work/\""));+ assert!(body.contains("href=\"/bare/\""));+ assert!(body.contains("non-bare <b>repo</b>"));+ assert!(!body.contains("<b>repo</b>"));+ assert!(body.contains("ago"), "idle column should show an age");+}++#[tokio::test]+async fn summary_shows_refs_commits_and_safe_readme() {+ let f = fixture();+ for repo in ["work", "bare"] {+ let (status, body) = get(&f, &format!("/{repo}/")).await;+ assert_eq!(status, StatusCode::OK, "{repo}");+ assert!(body.contains("feature/x"), "branch listed");+ assert!(body.contains("v1.0"), "tag listed");+ assert!(body.contains("change <greeting>"), "commit subject escaped");+ assert!(body.contains("<h1>Hello Forge</h1>"), "README rendered");+ assert!(body.contains("<strong>bold</strong>"));+ assert!(!body.contains("<script>"), "raw HTML in README must not pass through");+ assert!(body.contains(&format!("git clone http://example.test/{repo}")));+ }+ // /work redirects to /work/+ let (status, _) = get(&f, "/work").await;+ assert_eq!(status, StatusCode::PERMANENT_REDIRECT);+}++#[tokio::test]+async fn log_paginates() {+ let f = fixture();+ let (status, page1) = get(&f, "/work/log").await;+ assert_eq!(status, StatusCode::OK);+ assert!(page1.contains("older »"));+ assert!(!page1.contains("« newer"));+ assert!(!page1.contains("initial commit"));+ let (_, page2) = get(&f, "/work/log?ofs=50").await;+ assert!(page2.contains("initial commit"));+ assert!(page2.contains("« newer"));+ assert!(!page2.contains("older »"));+ // A branch name with a slash works as ?h=+ let (status, _) = get(&f, "/work/log?h=feature%2Fx").await;+ assert_eq!(status, StatusCode::OK);+}++#[tokio::test]+async fn commit_page_shows_colored_diff() {+ let f = fixture();+ let (status, body) = get(&f, &format!("/work/commit/{}", f.change_commit)).await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("src/main.rs"));+ assert!(body.contains("class=\"del\""), "{body}");+ assert!(body.contains("class=\"add\""));+ assert!(body.contains("println!("new line");") || body.contains("println!(\"new line\");"));+ // short ids work too+ let (status, _) = get(&f, &format!("/bare/commit/{}", &f.change_commit[..8])).await;+ assert_eq!(status, StatusCode::OK);+}++#[tokio::test]+async fn tree_blob_and_raw() {+ let f = fixture();+ let (status, body) = get(&f, "/work/tree").await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("href=\"/work/tree/src\""));+ assert!(body.contains("README.md"));++ let (status, body) = get(&f, "/work/tree/src?h=v1.0").await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("/work/blob/src/odd%20name%20%231.txt?h=v1.0"));++ let (status, body) = get(&f, "/work/blob/src/main.rs").await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("id=\"n3\""), "line numbers");+ assert!(body.contains("hl-"), "syntax highlighting classes");++ let (status, body) = get(&f, "/work/blob/src/odd%20name%20%231.txt").await;+ assert_eq!(status, StatusCode::OK);+ assert!(body.contains("spaces & hashes <ok>"));++ let (status, body) = get(&f, "/bare/raw/src/main.rs").await;+ assert_eq!(status, StatusCode::OK);+ assert_eq!(body, "fn main() {\n println!(\"new line\");\n}\n");++ // Old version via ?h=<first commit>+ let first = git(&f.dir.path().join("work"), &["rev-list", "--max-parents=0", "HEAD"]);+ let (_, body) = get(&f, &format!("/work/raw/src/main.rs?h={}", first.trim())).await;+ assert!(body.contains("old line"));+ assert!(!f.head.is_empty());+}++#[tokio::test]+async fn refuses_traversal_unknown_repos_and_odd_refs() {+ let f = fixture();+ for uri in [+ "/work/tree/..",+ "/work/tree/../../etc",+ "/work/tree/src/..%2F..%2F..%2Fetc%2Fpasswd",+ "/work/raw/%2E%2E/%2E%2E/etc/passwd",+ "/work/blob/.git/config",+ "/nope/",+ "/..%2F/",+ "/.git/",+ "/work/log?h=HEAD@%7B1%7D",+ "/work/log?h=main%5E",+ "/work/log?h=--all",+ "/work/commit/zzzz",+ ] {+ let (status, _) = get(&f, uri).await;+ assert_eq!(status, StatusCode::NOT_FOUND, "{uri}");+ }+ let (status, _) = get(&f, "/work/info/refs?service=git-receive-pack").await;+ assert_eq!(status, StatusCode::FORBIDDEN);+}++#[tokio::test(flavor = "multi_thread")]+async fn git_clone_over_http_works() {+ let f = fixture();+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();+ let addr = listener.local_addr().unwrap();+ let app = router(state_for(&f));+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });++ let out_dir = tempfile::tempdir().unwrap();+ // Protocol v2 (modern default) and v0 (older clients), bare and non-bare.+ for (repo, proto) in [("work", "2"), ("bare", "2"), ("work.git", "0"), ("bare", "0")] {+ let dest = out_dir.path().join(format!("{repo}-v{proto}"));+ let url = format!("http://{addr}/{repo}");+ let dest_s = dest.to_str().unwrap().to_string();+ let proto_cfg = format!("protocol.version={proto}");+ let status = tokio::task::spawn_blocking(move || {+ Command::new("git")+ .args(["-c", &proto_cfg, "clone", "-q", &url, &dest_s])+ .env("GIT_TERMINAL_PROMPT", "0")+ .status()+ .unwrap()+ })+ .await+ .unwrap();+ assert!(status.success(), "clone of {repo} (protocol v{proto}) failed");+ let head = git(&dest, &["rev-parse", "HEAD"]);+ assert_eq!(head.trim(), f.head, "{repo}");+ let tags = git(&dest, &["tag"]);+ assert!(tags.contains("v1.0"));+ let fsck = git(&dest, &["fsck", "--no-progress"]);+ assert!(!fsck.contains("error"));+ }++ // Pushing must fail.+ let work = out_dir.path().join("work-v2");+ let url = format!("http://{addr}/work");+ let status = tokio::task::spawn_blocking(move || {+ Command::new("git")+ .args(["push", "-q", &url, "HEAD:refs/heads/evil"])+ .current_dir(&work)+ .env("GIT_TERMINAL_PROMPT", "0")+ .stderr(std::process::Stdio::null())+ .status()+ .unwrap()+ })+ .await+ .unwrap();+ assert!(!status.success(), "push must be refused");+}