Krolyxon
Commit 55c1225

ferrenium55c1225

Ferrenium: skeleton, repository index and browsing (M0–M2)

A read-only Git repository browser that runs as a persistent daemon.

- M0: clap CLI (serve/check/example), validated TOML config, tcp/unix
  listeners, graceful shutdown, sd-notify, SIGHUP reload, embedded hashed
  assets, Nothing-style layout with theme toggle, strict CSP.
- M1: repository discovery under scan_path, metadata precedence
  ([[repo]] > git config > description file), hiding, copy-on-write
  index, inotify watcher with debounce plus poll fallback, index page.
- M2: catch-all dispatcher (longest repo and ref prefix), summary, tree,
  blob (syntect, monochrome classes, per-line balanced spans), raw with
  sandbox CSP, README rendering (comrak + ammonia, relative URL rewrite),
  refs page, branch picker, moka cache, weak ETags with 304s.
Author
krolyxon
Date
2026-11-05 21:16 +0530
Parents
None
Commit
55c1225ffbd1ca6c17705cb5974ad20c84c42fd3

+11915 −0

75 files changed

  1. A .gitignore +4 −0
  2. A Cargo.lock +3334 −0
  3. A Cargo.toml +42 −0
  4. A assets/css/base.css +106 −0
  5. A assets/css/components.css +423 −0
  6. A assets/css/fonts.css +95 −0
  7. A assets/css/repo.css +709 −0
  8. A assets/css/syntax.css +25 −0
  9. A assets/css/tokens.css +94 −0
  10. A assets/fonts/OFL.txt +98 −0
  11. A assets/fonts/doto-latin-ext-wght.woff2
  12. A assets/fonts/doto-latin-wght.woff2
  13. A assets/fonts/space-grotesk-latin-ext-wght.woff2
  14. A assets/fonts/space-grotesk-latin-wght.woff2
  15. A assets/fonts/space-grotesk-vietnamese-wght.woff2
  16. A assets/fonts/space-mono-latin-400.woff2
  17. A assets/fonts/space-mono-latin-700.woff2
  18. A assets/fonts/space-mono-latin-ext-400.woff2
  19. A assets/fonts/space-mono-latin-ext-700.woff2
  20. A assets/fonts/space-mono-vietnamese-400.woff2
  21. A assets/fonts/space-mono-vietnamese-700.woff2
  22. A assets/icons/LICENSE-lucide.txt +15 −0
  23. A assets/icons/favicon.svg +5 −0
  24. A assets/icons/sprite.svg +12 −0
  25. A assets/js/app.js +129 −0
  26. A assets/js/theme.js +45 −0
  27. A dist/Caddyfile +5 −0
  28. A dist/ferrenium.service +43 −0
  29. A dist/ferrenium.toml +60 −0
  30. A src/cache.rs +143 −0
  31. A src/config.rs +643 −0
  32. A src/examples.rs +5 −0
  33. A src/git/mod.rs +226 −0
  34. A src/git/refs.rs +167 −0
  35. A src/git/tree.rs +125 −0
  36. A src/handlers/blob.rs +223 −0
  37. A src/handlers/error.rs +34 −0
  38. A src/handlers/index.rs +156 −0
  39. A src/handlers/mod.rs +71 −0
  40. A src/handlers/raw.rs +102 −0
  41. A src/handlers/refs.rs +83 −0
  42. A src/handlers/repo.rs +306 −0
  43. A src/handlers/summary.rs +178 −0
  44. A src/handlers/tree.rs +201 −0
  45. A src/index/meta.rs +167 −0
  46. A src/index/mod.rs +253 −0
  47. A src/index/scan.rs +186 −0
  48. A src/index/watch.rs +266 −0
  49. A src/lib.rs +12 −0
  50. A src/logging.rs +56 −0
  51. A src/main.rs +183 −0
  52. A src/render/highlight.rs +252 −0
  53. A src/render/markdown.rs +314 −0
  54. A src/render/mod.rs +115 −0
  55. A src/render/time.rs +63 −0
  56. A src/server/assets.rs +200 −0
  57. A src/server/dispatch.rs +141 −0
  58. A src/server/headers.rs +19 −0
  59. A src/server/mod.rs +297 −0
  60. A src/state.rs +109 −0
  61. A templates/base.html +41 −0
  62. A templates/blob.html +42 −0
  63. A templates/crumbs.html +4 −0
  64. A templates/error.html +8 −0
  65. A templates/index.html +56 −0
  66. A templates/macros.html +24 −0
  67. A templates/ref_table.html +16 −0
  68. A templates/refs.html +23 −0
  69. A templates/repo_nav.html +36 −0
  70. A templates/summary.html +66 −0
  71. A templates/tree.html +11 −0
  72. A tests/browse.rs +409 −0
  73. A tests/common/mod.rs +153 −0
  74. A tests/http.rs +215 −0
  75. A tests/index.rs +271 −0
A .gitignore View
@@ -0,0 +1,4 @@
1+/target
2+/dev/
3+/CLAUDE.md
4+/PLAN.md
A Cargo.lock View

[Diff too large] View file

A Cargo.toml View
@@ -0,0 +1,42 @@
1+[package]
2+name = "ferrenium"
3+version = "0.1.0"
4+edition = "2024"
5+rust-version = "1.89"
6+description = "A fast, read-only Git repository browser"
7+publish = false
8+
9+[dependencies]
10+ammonia = "4.2"
11+anyhow = "1.0.104"
12+arc-swap = "1.9.3"
13+askama = "0.16.1"
14+axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "query"] }
15+clap = { version = "4.6.7", features = ["derive", "env"] }
16+comrak = { version = "0.59", default-features = false }
17+# No `extras`/`interrupt`: they install their own signal handlers.
18+gix = { version = "0.90.0", default-features = false, features = ["max-performance", "revision", "blob-diff", "blame", "mailmap", "worktree-archive", "sha1"] }
19+jiff = "0.2.39"
20+mime_guess = "2.0.5"
21+moka = { version = "0.12", features = ["sync"] }
22+notify = { version = "8.2.1", default-features = false }
23+rust-embed = "8.13.0"
24+sd-notify = "0.5.0"
25+serde = { version = "1.0.229", features = ["derive"] }
26+syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] }
27+thiserror = "2.0.21"
28+tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] }
29+toml = "1.2.0"
30+tower = { version = "0.5.3", features = ["util"] }
31+tower-http = { version = "0.7.1", features = ["trace", "timeout", "catch-panic", "compression-gzip", "compression-br", "set-header"] }
32+tracing = "0.1.44"
33+tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
34+two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
35+
36+[dev-dependencies]
37+tempfile = "3"
38+
39+[profile.release]
40+lto = "thin"
41+codegen-units = 1
42+strip = true
A assets/css/base.css View
@@ -0,0 +1,106 @@
1+/* Base: reset, document typography, page shell. */
2+
3+*,
4+*::before,
5+*::after {
6+ box-sizing: border-box;
7+}
8+
9+html {
10+ -webkit-text-size-adjust: 100%;
11+ text-size-adjust: 100%;
12+}
13+
14+body {
15+ margin: 0;
16+ min-height: 100vh;
17+ display: flex;
18+ flex-direction: column;
19+ background: var(--black);
20+ color: var(--text-primary);
21+ font-family: var(--font-body);
22+ font-size: var(--body);
23+ font-weight: 400;
24+ line-height: 1.5;
25+ -webkit-font-smoothing: antialiased;
26+ -moz-osx-font-smoothing: grayscale;
27+ text-rendering: optimizeLegibility;
28+ font-synthesis: none;
29+}
30+
31+h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ul, ol {
32+ margin: 0;
33+}
34+
35+h1, h2, h3, h4, h5, h6 {
36+ font-weight: 500;
37+ color: var(--text-display);
38+}
39+
40+ul, ol {
41+ padding: 0;
42+}
43+
44+img, svg, video {
45+ display: block;
46+ max-width: 100%;
47+}
48+
49+button, input, select, textarea {
50+ font: inherit;
51+ color: inherit;
52+}
53+
54+button {
55+ cursor: pointer;
56+}
57+
58+a {
59+ color: var(--interactive);
60+ text-decoration: none;
61+}
62+
63+a:hover {
64+ text-decoration: underline;
65+ text-underline-offset: 3px;
66+ text-decoration-thickness: 1px;
67+}
68+
69+code, kbd, samp, pre {
70+ font-family: var(--font-code);
71+ font-size: var(--body-sm);
72+}
73+
74+::selection {
75+ background: var(--text-display);
76+ color: var(--black);
77+}
78+
79+:focus-visible {
80+ outline: 1px solid var(--text-display);
81+ outline-offset: 2px;
82+}
83+
84+[hidden] {
85+ display: none !important;
86+}
87+
88+/* Page shell: one centered column with responsive gutters. */
89+.shell {
90+ width: 100%;
91+ max-width: calc(var(--page-max) + 2 * var(--gutter));
92+ margin-inline: auto;
93+ padding-inline: var(--gutter);
94+}
95+
96+main.shell {
97+ flex: 1;
98+}
99+
100+@media (prefers-reduced-motion: reduce) {
101+ *,
102+ *::before,
103+ *::after {
104+ transition-duration: 0ms !important;
105+ }
106+}
A assets/css/components.css View
@@ -0,0 +1,423 @@
1+/* Components: labels, display type, top bar, theme switch, page patterns. */
2+
3+/* ---- Type roles ------------------------------------------------------- */
4+
5+.label {
6+ font-family: var(--font-mono);
7+ font-size: var(--label);
8+ font-weight: 400;
9+ line-height: 1.2;
10+ letter-spacing: 0.08em;
11+ text-transform: uppercase;
12+ color: var(--text-secondary);
13+}
14+
15+.dim {
16+ color: var(--text-disabled);
17+}
18+
19+.doto {
20+ font-family: var(--font-display);
21+ font-weight: 600;
22+ color: var(--text-display);
23+}
24+
25+.display-xl {
26+ font-size: var(--display-xl);
27+ line-height: 1;
28+ letter-spacing: -0.03em;
29+}
30+
31+.display-lg {
32+ font-size: var(--display-lg);
33+ line-height: 1.05;
34+ letter-spacing: -0.02em;
35+}
36+
37+.display-md {
38+ font-size: var(--display-md);
39+ line-height: 1.1;
40+ letter-spacing: -0.02em;
41+}
42+
43+/* Inline status text: [LOADING], [COPIED], [ERROR: …]. */
44+.status {
45+ font-family: var(--font-mono);
46+ font-size: var(--caption);
47+ font-weight: 400;
48+ line-height: 1.4;
49+ letter-spacing: 0.04em;
50+ text-transform: uppercase;
51+ color: var(--text-secondary);
52+}
53+
54+.status-error {
55+ color: var(--error);
56+}
57+
58+/* ---- Buttons ---------------------------------------------------------- */
59+
60+.btn-ghost {
61+ display: inline-flex;
62+ align-items: center;
63+ min-height: 44px;
64+ font-family: var(--font-mono);
65+ font-size: 13px;
66+ letter-spacing: 0.06em;
67+ text-transform: uppercase;
68+ color: var(--text-secondary);
69+ background: none;
70+ border: 0;
71+ transition: color var(--dur-micro) var(--ease);
72+}
73+
74+.btn-ghost:hover {
75+ color: var(--text-display);
76+ text-decoration: none;
77+}
78+
79+/* ---- Top bar ---------------------------------------------------------- */
80+
81+.topbar {
82+ display: flex;
83+ align-items: center;
84+ justify-content: space-between;
85+ gap: var(--space-md);
86+ min-height: 64px;
87+ padding-block: var(--space-md);
88+}
89+
90+.topbar-home {
91+ color: var(--text-display);
92+ white-space: nowrap;
93+ overflow: hidden;
94+ text-overflow: ellipsis;
95+}
96+
97+.topbar-home:hover {
98+ text-decoration: none;
99+}
100+
101+/* ---- Segmented control ------------------------------------------------ */
102+
103+.segmented {
104+ display: inline-flex;
105+ flex: none;
106+ padding: var(--space-2xs);
107+ border: 1px solid var(--border-visible);
108+ border-radius: 999px;
109+}
110+
111+.segmented button,
112+.segmented a {
113+ display: inline-flex;
114+ align-items: center;
115+ height: 30px;
116+ padding: 0 var(--space-md);
117+ border: 0;
118+ border-radius: 999px;
119+ background: transparent;
120+ font-family: var(--font-mono);
121+ font-size: var(--label);
122+ letter-spacing: 0.08em;
123+ text-transform: uppercase;
124+ color: var(--text-secondary);
125+ transition:
126+ background-color 200ms var(--ease),
127+ color 200ms var(--ease);
128+}
129+
130+.segmented button:hover,
131+.segmented a:hover {
132+ color: var(--text-primary);
133+ text-decoration: none;
134+}
135+
136+.segmented button[aria-pressed="true"],
137+.segmented a[aria-current="true"] {
138+ background: var(--text-display);
139+ color: var(--black);
140+}
141+
142+.segmented-sm button,
143+.segmented-sm a {
144+ height: 26px;
145+ padding: 0 var(--space-sm);
146+}
147+
148+@media (pointer: coarse) {
149+ .segmented button,
150+ .segmented a {
151+ height: 40px;
152+ }
153+}
154+
155+/* The theme switch needs JS; without it the OS preference applies. */
156+html:not(.js) .theme-switch {
157+ display: none;
158+}
159+
160+/* ---- Site header / footer --------------------------------------------- */
161+
162+.site-header {
163+ padding-block: var(--space-sm);
164+}
165+
166+.footer {
167+ display: flex;
168+ flex-wrap: wrap;
169+ align-items: flex-end;
170+ justify-content: space-between;
171+ gap: var(--space-md);
172+ margin-top: var(--space-4xl);
173+ padding-block: var(--space-xl);
174+}
175+
176+.site-footer {
177+ font-size: var(--body-sm);
178+ color: var(--text-secondary);
179+}
180+
181+/* ---- Index ------------------------------------------------------------ */
182+
183+.hero {
184+ display: flex;
185+ flex-wrap: wrap;
186+ align-items: flex-end;
187+ justify-content: space-between;
188+ gap: var(--space-md) var(--space-xl);
189+ padding-top: var(--space-3xl);
190+}
191+
192+.hero-main {
193+ min-width: 0;
194+}
195+
196+.hero h1 {
197+ overflow-wrap: anywhere;
198+}
199+
200+.hero-desc {
201+ margin-top: var(--space-sm);
202+ max-width: 60ch;
203+ color: var(--text-secondary);
204+}
205+
206+.hero-meta {
207+ white-space: nowrap;
208+ padding-bottom: var(--space-xs);
209+}
210+
211+/* ---- Inputs ----------------------------------------------------------- */
212+
213+.field {
214+ display: flex;
215+ flex-direction: column;
216+ gap: var(--space-sm);
217+}
218+
219+.field input {
220+ width: 100%;
221+ min-height: 44px;
222+ padding: var(--space-sm) 0;
223+ border: 0;
224+ border-bottom: 1px solid var(--border-visible);
225+ border-radius: 0;
226+ background: transparent;
227+ font-family: var(--font-mono);
228+ font-size: var(--body);
229+ color: var(--text-primary);
230+ outline: none;
231+ transition: border-color var(--dur-micro) var(--ease);
232+ -webkit-appearance: none;
233+ appearance: none;
234+}
235+
236+.field input::placeholder {
237+ color: var(--text-disabled);
238+}
239+
240+.field input:focus {
241+ border-bottom-color: var(--text-primary);
242+}
243+
244+.field input::-webkit-search-cancel-button {
245+ display: none;
246+}
247+
248+/* ---- Index: filter, sort, repository rows ----------------------------- */
249+
250+.index-tools {
251+ display: flex;
252+ flex-wrap: wrap;
253+ align-items: flex-end;
254+ justify-content: space-between;
255+ gap: var(--space-md) var(--space-xl);
256+ margin-top: var(--space-2xl);
257+}
258+
259+.index-tools .field {
260+ flex: 1 1 240px;
261+ max-width: 360px;
262+}
263+
264+/* Bracket nav: [ ACTIVE ] INACTIVE */
265+.sort,
266+.bracket-nav {
267+ display: flex;
268+ gap: var(--space-sm);
269+}
270+
271+.sort a,
272+.bracket-nav a {
273+ display: inline-flex;
274+ align-items: center;
275+ min-height: 44px;
276+ padding-inline: var(--space-xs);
277+ color: var(--text-disabled);
278+ transition: color var(--dur-micro) var(--ease);
279+}
280+
281+.sort a:hover,
282+.bracket-nav a:hover {
283+ color: var(--text-secondary);
284+ text-decoration: none;
285+}
286+
287+.sort a.active,
288+.bracket-nav a.active {
289+ color: var(--text-display);
290+}
291+
292+.sort a.active::before,
293+.bracket-nav a.active::before {
294+ content: "[ ";
295+ white-space: pre;
296+}
297+
298+.sort a.active::after,
299+.bracket-nav a.active::after {
300+ content: " ]";
301+ white-space: pre;
302+}
303+
304+.repo-section {
305+ margin-top: var(--space-2xl);
306+}
307+
308+.section-label {
309+ padding-bottom: var(--space-sm);
310+}
311+
312+.repo-list {
313+ list-style: none;
314+ border-top: 1px solid var(--border);
315+}
316+
317+.repo-row {
318+ display: flex;
319+ align-items: baseline;
320+ justify-content: space-between;
321+ gap: var(--space-md);
322+ border-bottom: 1px solid var(--border);
323+}
324+
325+.repo-link {
326+ display: flex;
327+ flex-direction: column;
328+ gap: var(--space-2xs);
329+ flex: 1;
330+ min-width: 0;
331+ padding-block: 14px;
332+ color: var(--text-primary);
333+}
334+
335+.repo-link:hover {
336+ text-decoration: none;
337+}
338+
339+.repo-name {
340+ font-weight: 500;
341+ color: var(--text-display);
342+ overflow-wrap: anywhere;
343+}
344+
345+.repo-link:hover .repo-name {
346+ text-decoration: underline;
347+ text-underline-offset: 3px;
348+ text-decoration-thickness: 1px;
349+}
350+
351+.repo-desc {
352+ color: var(--text-secondary);
353+}
354+
355+@media (min-width: 768px) {
356+ .repo-link {
357+ flex-direction: row;
358+ align-items: baseline;
359+ gap: var(--space-md);
360+ }
361+
362+ .repo-name {
363+ flex: none;
364+ max-width: 50%;
365+ }
366+
367+ .repo-desc {
368+ overflow: hidden;
369+ white-space: nowrap;
370+ text-overflow: ellipsis;
371+ }
372+}
373+
374+.repo-meta {
375+ display: inline-flex;
376+ align-items: center;
377+ gap: var(--space-sm);
378+ flex: none;
379+ color: var(--text-disabled);
380+ white-space: nowrap;
381+}
382+
383+/* Data-status dot: pushed in the last 24 hours. */
384+.dot {
385+ width: 6px;
386+ height: 6px;
387+ border-radius: 50%;
388+ background: var(--accent);
389+}
390+
391+.no-matches {
392+ padding-block: var(--space-2xl);
393+}
394+
395+/* ---- Empty state ------------------------------------------------------ */
396+
397+.empty {
398+ padding-block: var(--space-4xl);
399+ text-align: center;
400+}
401+
402+.empty-title {
403+ color: var(--text-secondary);
404+}
405+
406+.empty-desc {
407+ margin-top: var(--space-xs);
408+ color: var(--text-disabled);
409+}
410+
411+/* ---- Error page ------------------------------------------------------- */
412+
413+.error-page {
414+ display: flex;
415+ flex-direction: column;
416+ align-items: flex-start;
417+ gap: var(--space-md);
418+ padding-top: var(--space-4xl);
419+}
420+
421+.error-page .btn-ghost {
422+ margin-top: var(--space-xl);
423+}
A assets/css/fonts.css View
@@ -0,0 +1,95 @@
1+/* Self-hosted fonts (OFL, see fonts/OFL.txt). Subsets via Fontsource. */
2+
3+/* Doto — dot-matrix display face, hero moments only (36px+). */
4+@font-face {
5+ font-family: "Doto";
6+ font-style: normal;
7+ font-weight: 100 900;
8+ font-display: block;
9+ src: url(../fonts/doto-latin-ext-wght.woff2) format("woff2");
10+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
11+}
12+@font-face {
13+ font-family: "Doto";
14+ font-style: normal;
15+ font-weight: 100 900;
16+ font-display: block;
17+ src: url(../fonts/doto-latin-wght.woff2) format("woff2");
18+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
19+}
20+
21+/* Space Grotesk — body and UI (variable 300–700). */
22+@font-face {
23+ font-family: "Space Grotesk";
24+ font-style: normal;
25+ font-weight: 300 700;
26+ font-display: swap;
27+ src: url(../fonts/space-grotesk-vietnamese-wght.woff2) format("woff2");
28+ unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
29+}
30+@font-face {
31+ font-family: "Space Grotesk";
32+ font-style: normal;
33+ font-weight: 300 700;
34+ font-display: swap;
35+ src: url(../fonts/space-grotesk-latin-ext-wght.woff2) format("woff2");
36+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
37+}
38+@font-face {
39+ font-family: "Space Grotesk";
40+ font-style: normal;
41+ font-weight: 300 700;
42+ font-display: swap;
43+ src: url(../fonts/space-grotesk-latin-wght.woff2) format("woff2");
44+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
45+}
46+
47+/* Space Mono — data, labels and code. */
48+@font-face {
49+ font-family: "Space Mono";
50+ font-style: normal;
51+ font-weight: 400;
52+ font-display: swap;
53+ src: url(../fonts/space-mono-vietnamese-400.woff2) format("woff2");
54+ unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
55+}
56+@font-face {
57+ font-family: "Space Mono";
58+ font-style: normal;
59+ font-weight: 400;
60+ font-display: swap;
61+ src: url(../fonts/space-mono-latin-ext-400.woff2) format("woff2");
62+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
63+}
64+@font-face {
65+ font-family: "Space Mono";
66+ font-style: normal;
67+ font-weight: 400;
68+ font-display: swap;
69+ src: url(../fonts/space-mono-latin-400.woff2) format("woff2");
70+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
71+}
72+@font-face {
73+ font-family: "Space Mono";
74+ font-style: normal;
75+ font-weight: 700;
76+ font-display: swap;
77+ src: url(../fonts/space-mono-vietnamese-700.woff2) format("woff2");
78+ unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
79+}
80+@font-face {
81+ font-family: "Space Mono";
82+ font-style: normal;
83+ font-weight: 700;
84+ font-display: swap;
85+ src: url(../fonts/space-mono-latin-ext-700.woff2) format("woff2");
86+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
87+}
88+@font-face {
89+ font-family: "Space Mono";
90+ font-style: normal;
91+ font-weight: 700;
92+ font-display: swap;
93+ src: url(../fonts/space-mono-latin-700.woff2) format("woff2");
94+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
95+}
A assets/css/repo.css View
@@ -0,0 +1,709 @@
1+/* Repository pages: nav, ref picker, summary, tree, blob, refs, Markdown. */
2+
3+.mono {
4+ font-family: var(--font-mono);
5+}
6+
7+.icon {
8+ width: 16px;
9+ height: 16px;
10+ flex: none;
11+}
12+
13+/* ---- Repository bar: [ SUMMARY ] TREE REFS · ref picker ------------- */
14+
15+.repo-bar {
16+ display: flex;
17+ flex-wrap: wrap;
18+ align-items: center;
19+ justify-content: space-between;
20+ gap: var(--space-sm) var(--space-md);
21+ padding-top: var(--space-sm);
22+}
23+
24+.bracket-nav {
25+ overflow-x: auto;
26+ scrollbar-width: none;
27+}
28+
29+.bracket-nav a {
30+ white-space: nowrap;
31+}
32+
33+.ref-picker {
34+ position: relative;
35+}
36+
37+.ref-picker summary {
38+ display: inline-flex;
39+ align-items: center;
40+ gap: var(--space-sm);
41+ min-height: 44px;
42+ padding: 0 var(--space-md);
43+ border: 1px solid var(--border-visible);
44+ border-radius: 999px;
45+ list-style: none;
46+ cursor: pointer;
47+ color: var(--text-secondary);
48+ transition: border-color var(--dur-micro) var(--ease);
49+}
50+
51+.ref-picker summary::-webkit-details-marker {
52+ display: none;
53+}
54+
55+.ref-picker summary:hover,
56+.ref-picker[open] summary {
57+ border-color: var(--text-secondary);
58+}
59+
60+.ref-current {
61+ max-width: 24ch;
62+ overflow: hidden;
63+ text-overflow: ellipsis;
64+ white-space: nowrap;
65+ font-family: var(--font-mono);
66+ font-size: var(--caption);
67+ color: var(--text-primary);
68+}
69+
70+.ref-picker[open] .chevron {
71+ transform: rotate(180deg);
72+}
73+
74+.ref-menu {
75+ position: absolute;
76+ z-index: 10;
77+ right: 0;
78+ top: calc(100% + var(--space-xs));
79+ width: max-content;
80+ min-width: 240px;
81+ max-width: min(360px, calc(100vw - 2 * var(--gutter)));
82+ max-height: 60vh;
83+ overflow-y: auto;
84+ padding: var(--space-sm) 0;
85+ border: 1px solid var(--border-visible);
86+ border-radius: 8px;
87+ background: var(--surface-raised);
88+}
89+
90+.ref-group {
91+ padding: var(--space-sm) var(--space-md) var(--space-xs);
92+ color: var(--text-disabled);
93+}
94+
95+.ref-menu ul {
96+ list-style: none;
97+}
98+
99+.ref-menu li a {
100+ display: flex;
101+ align-items: center;
102+ min-height: 44px;
103+ padding: 0 var(--space-md);
104+ border-left: 2px solid transparent;
105+ font-family: var(--font-mono);
106+ font-size: var(--caption);
107+ color: var(--text-primary);
108+ overflow-wrap: anywhere;
109+}
110+
111+.ref-menu li a:hover {
112+ color: var(--text-display);
113+ text-decoration: none;
114+}
115+
116+.ref-menu li a.active {
117+ border-left-color: var(--accent);
118+ color: var(--text-display);
119+}
120+
121+.ref-all {
122+ display: flex;
123+ align-items: center;
124+ min-height: 44px;
125+ padding: 0 var(--space-md);
126+ margin-top: var(--space-xs);
127+ border-top: 1px solid var(--border);
128+}
129+
130+/* ---- Breadcrumb: secondary path, current segment in display ------------ */
131+
132+.crumbs {
133+ padding-top: var(--space-2xl);
134+ font-size: var(--heading);
135+ font-weight: 400;
136+ line-height: 1.25;
137+ color: var(--text-secondary);
138+ overflow-wrap: anywhere;
139+}
140+
141+.crumbs a {
142+ color: var(--text-secondary);
143+}
144+
145+.crumbs a:hover {
146+ color: var(--text-display);
147+}
148+
149+.crumb-sep {
150+ padding: 0 var(--space-sm);
151+ color: var(--text-disabled);
152+}
153+
154+.crumb-here {
155+ color: var(--text-display);
156+}
157+
158+/* ---- Summary ------------------------------------------------------------ */
159+
160+.repo-hero {
161+ padding-top: var(--space-2xl);
162+}
163+
164+.repo-title {
165+ font-weight: 500;
166+ overflow-wrap: anywhere;
167+}
168+
169+.repo-about {
170+ margin-top: var(--space-sm);
171+ max-width: 60ch;
172+ color: var(--text-secondary);
173+}
174+
175+.repo-grid {
176+ display: grid;
177+ grid-template-columns: minmax(0, 1fr);
178+ gap: var(--space-2xl);
179+ margin-top: var(--space-xl);
180+}
181+
182+@media (min-width: 768px) {
183+ .repo-grid {
184+ grid-template-columns: minmax(0, 1fr) var(--rail);
185+ gap: var(--space-xl);
186+ }
187+}
188+
189+@media (min-width: 1200px) {
190+ .repo-grid {
191+ gap: var(--space-2xl);
192+ }
193+}
194+
195+.rail {
196+ display: flex;
197+ flex-direction: column;
198+ gap: var(--space-xl);
199+ min-width: 0;
200+}
201+
202+.stats {
203+ border-top: 1px solid var(--border);
204+}
205+
206+.stat {
207+ display: flex;
208+ align-items: baseline;
209+ justify-content: space-between;
210+ gap: var(--space-md);
211+ padding-block: 12px;
212+ border-bottom: 1px solid var(--border);
213+}
214+
215+.stat dd {
216+ margin: 0;
217+ font-family: var(--font-mono);
218+ font-size: var(--body-sm);
219+ color: var(--text-primary);
220+ text-align: right;
221+ overflow-wrap: anywhere;
222+}
223+
224+.rail-head {
225+ display: flex;
226+ align-items: center;
227+ justify-content: space-between;
228+ gap: var(--space-sm);
229+ min-height: 30px;
230+}
231+
232+.rail-block > .label {
233+ display: block;
234+}
235+
236+.clone-row {
237+ display: flex;
238+ align-items: center;
239+ gap: var(--space-xs);
240+ margin-top: var(--space-sm);
241+}
242+
243+.clone-input {
244+ flex: 1;
245+ min-width: 0;
246+ min-height: 44px;
247+ padding: 0;
248+ border: 0;
249+ border-bottom: 1px solid var(--border-visible);
250+ border-radius: 0;
251+ background: transparent;
252+ font-family: var(--font-mono);
253+ font-size: var(--caption);
254+ color: var(--text-primary);
255+ outline: none;
256+ text-overflow: ellipsis;
257+}
258+
259+.clone-input:focus {
260+ border-bottom-color: var(--text-primary);
261+}
262+
263+.icon-btn {
264+ display: inline-flex;
265+ align-items: center;
266+ justify-content: center;
267+ width: 44px;
268+ height: 44px;
269+ flex: none;
270+ padding: 0;
271+ border: 0;
272+ background: none;
273+ color: var(--text-secondary);
274+ transition: color var(--dur-micro) var(--ease);
275+}
276+
277+.icon-btn:hover {
278+ color: var(--text-display);
279+}
280+
281+/* Copying and switching protocols need JS; without it every URL shows. */
282+html:not(.js) .clone-switch,
283+html:not(.js) [data-copy],
284+html:not(.js) .copy-status {
285+ display: none;
286+}
287+
288+.copy-status {
289+ min-height: 1.4em;
290+ margin-top: var(--space-xs);
291+}
292+
293+.latest-subject {
294+ margin-top: var(--space-sm);
295+ color: var(--text-primary);
296+ overflow-wrap: anywhere;
297+}
298+
299+.latest-meta {
300+ margin-top: var(--space-xs);
301+ font-family: var(--font-mono);
302+ font-size: var(--caption);
303+ color: var(--text-secondary);
304+}
305+
306+.sha {
307+ font-family: var(--font-mono);
308+}
309+
310+/* ---- Tree listing --------------------------------------------------------- */
311+
312+.tree {
313+ list-style: none;
314+ margin-top: var(--space-lg);
315+ border-top: 1px solid var(--border);
316+}
317+
318+.repo-main > .tree:first-child {
319+ margin-top: 0;
320+}
321+
322+.tree-row {
323+ display: flex;
324+ align-items: center;
325+ justify-content: space-between;
326+ gap: var(--space-md);
327+ min-height: 44px;
328+ border-bottom: 1px solid var(--border);
329+}
330+
331+.tree-name {
332+ display: flex;
333+ align-items: center;
334+ gap: var(--space-sm);
335+ flex: 1;
336+ min-width: 0;
337+ min-height: 44px;
338+ color: var(--text-primary);
339+ overflow-wrap: anywhere;
340+}
341+
342+.tree-name .icon {
343+ color: var(--text-disabled);
344+}
345+
346+.tree-name .icon-folder {
347+ color: var(--text-secondary);
348+}
349+
350+.tree-meta {
351+ flex: none;
352+ font-family: var(--font-mono);
353+ font-size: var(--caption);
354+ color: var(--text-disabled);
355+ white-space: nowrap;
356+}
357+
358+.tree-foot {
359+ margin-top: var(--space-md);
360+}
361+
362+/* ---- README ------------------------------------------------------------- */
363+
364+.readme {
365+ margin-top: var(--space-2xl);
366+}
367+
368+.readme-name {
369+ display: inline-block;
370+ margin-bottom: var(--space-md);
371+ color: var(--text-secondary);
372+}
373+
374+/* ---- Blob --------------------------------------------------------------- */
375+
376+.blob-bar {
377+ display: flex;
378+ flex-wrap: wrap;
379+ align-items: center;
380+ justify-content: space-between;
381+ gap: var(--space-sm) var(--space-md);
382+ margin-top: var(--space-md);
383+}
384+
385+.blob-actions {
386+ display: flex;
387+ flex-wrap: wrap;
388+ align-items: center;
389+ gap: var(--space-md);
390+}
391+
392+.blob-note {
393+ padding-block: var(--space-2xl);
394+ border-top: 1px solid var(--border);
395+ overflow-wrap: anywhere;
396+}
397+
398+.code-wrap {
399+ margin-top: var(--space-sm);
400+ border-top: 1px solid var(--border);
401+ border-bottom: 1px solid var(--border);
402+ overflow-x: auto;
403+}
404+
405+.code {
406+ width: 100%;
407+ border-collapse: collapse;
408+ font-family: var(--font-code);
409+ font-size: 13px;
410+ line-height: 20px;
411+ tab-size: 4;
412+}
413+
414+.code td {
415+ padding: 0;
416+ vertical-align: top;
417+}
418+
419+.code .ln {
420+ width: 1%;
421+ border-left: 2px solid transparent;
422+ text-align: right;
423+ user-select: none;
424+ -webkit-user-select: none;
425+}
426+
427+.code .ln a {
428+ display: block;
429+ padding: 0 var(--space-md) 0 var(--space-sm);
430+ color: var(--text-disabled);
431+}
432+
433+.code .ln a:hover {
434+ color: var(--text-secondary);
435+ text-decoration: none;
436+}
437+
438+.code .lc {
439+ padding: 0 var(--space-md);
440+ white-space: pre;
441+ color: var(--text-primary);
442+}
443+
444+.code tr:first-child td {
445+ padding-top: var(--space-sm);
446+}
447+
448+.code tr:last-child td {
449+ padding-bottom: var(--space-sm);
450+}
451+
452+/* Selected lines: #L12 (CSS) or #L12-L20 (JS). */
453+.code tr:target,
454+.code tr.selected {
455+ background: var(--surface-raised);
456+}
457+
458+.code tr:target .ln,
459+.code tr.selected .ln {
460+ border-left-color: var(--accent);
461+}
462+
463+.code tr:target .ln a,
464+.code tr.selected .ln a {
465+ color: var(--text-display);
466+}
467+
468+/* The one break on the blob page: images float on a dot grid. */
469+.image-view {
470+ display: flex;
471+ justify-content: center;
472+ margin-top: var(--space-sm);
473+ padding: var(--space-xl) var(--space-md);
474+ border: 1px solid var(--border);
475+ border-radius: 4px;
476+ background-image: radial-gradient(circle, var(--border-visible) 1px, transparent 1px);
477+ background-size: 16px 16px;
478+}
479+
480+.image-view img {
481+ max-height: 70vh;
482+ object-fit: contain;
483+}
484+
485+.blob-markdown {
486+ margin-top: var(--space-xl);
487+}
488+
489+/* ---- Refs --------------------------------------------------------------- */
490+
491+.refs-section {
492+ margin-top: var(--space-2xl);
493+}
494+
495+.table-wrap {
496+ overflow-x: auto;
497+}
498+
499+.data-table {
500+ width: 100%;
501+ border-collapse: collapse;
502+}
503+
504+.data-table th {
505+ padding: 12px 16px 12px 0;
506+ border-bottom: 1px solid var(--border-visible);
507+ text-align: left;
508+ font-weight: 400;
509+ white-space: nowrap;
510+}
511+
512+.data-table td {
513+ padding: 12px 16px 12px 0;
514+ border-bottom: 1px solid var(--border);
515+ vertical-align: baseline;
516+}
517+
518+.data-table .num {
519+ padding-right: 0;
520+ text-align: right;
521+ white-space: nowrap;
522+ font-family: var(--font-mono);
523+ font-size: var(--caption);
524+ color: var(--text-disabled);
525+}
526+
527+.data-table th.num {
528+ font-size: var(--label);
529+ color: var(--text-secondary);
530+}
531+
532+.ref-name {
533+ font-family: var(--font-mono);
534+ font-size: var(--body-sm);
535+ overflow-wrap: anywhere;
536+}
537+
538+.ref-subject {
539+ color: var(--text-secondary);
540+ overflow-wrap: anywhere;
541+}
542+
543+.chip {
544+ display: inline-block;
545+ margin-left: var(--space-sm);
546+ padding: 2px var(--space-sm);
547+ border: 1px solid var(--border-visible);
548+ border-radius: 999px;
549+ font-family: var(--font-mono);
550+ font-size: var(--label);
551+ letter-spacing: 0.08em;
552+ text-transform: uppercase;
553+ color: var(--text-secondary);
554+ white-space: nowrap;
555+}
556+
557+/* ---- Markdown ------------------------------------------------------------ */
558+
559+.markdown {
560+ max-width: 72ch;
561+ overflow-wrap: break-word;
562+ color: var(--text-primary);
563+}
564+
565+.markdown > * + * {
566+ margin-top: var(--space-md);
567+}
568+
569+.markdown h1,
570+.markdown h2,
571+.markdown h3,
572+.markdown h4,
573+.markdown h5,
574+.markdown h6 {
575+ margin-top: var(--space-xl);
576+ line-height: 1.25;
577+}
578+
579+.markdown > :first-child {
580+ margin-top: 0;
581+}
582+
583+.markdown h1 {
584+ font-size: var(--heading);
585+}
586+
587+.markdown h2 {
588+ font-size: var(--subheading);
589+}
590+
591+.markdown h3,
592+.markdown h4,
593+.markdown h5,
594+.markdown h6 {
595+ font-size: var(--body);
596+}
597+
598+.markdown a.anchor {
599+ display: none;
600+}
601+
602+.markdown ul,
603+.markdown ol {
604+ padding-left: var(--space-lg);
605+}
606+
607+.markdown li + li,
608+.markdown li > ul,
609+.markdown li > ol {
610+ margin-top: var(--space-xs);
611+}
612+
613+.markdown .contains-task-list {
614+ list-style: none;
615+ padding-left: var(--space-xs);
616+}
617+
618+.markdown input[type="checkbox"] {
619+ margin: 0 var(--space-sm) 0 0;
620+ vertical-align: middle;
621+ accent-color: var(--text-display);
622+}
623+
624+.markdown code {
625+ padding: 1px var(--space-xs);
626+ border-radius: 4px;
627+ background: var(--surface-raised);
628+ font-size: 0.875em;
629+}
630+
631+.markdown pre {
632+ padding: var(--space-md);
633+ border: 1px solid var(--border);
634+ border-radius: 8px;
635+ background: var(--surface);
636+ overflow-x: auto;
637+ line-height: 20px;
638+ tab-size: 4;
639+}
640+
641+.markdown pre code {
642+ padding: 0;
643+ background: none;
644+ font-size: 13px;
645+}
646+
647+.markdown pre.plain {
648+ max-width: none;
649+ white-space: pre-wrap;
650+}
651+
652+.markdown blockquote {
653+ padding-left: var(--space-md);
654+ border-left: 2px solid var(--border-visible);
655+ color: var(--text-secondary);
656+}
657+
658+.markdown hr {
659+ border: 0;
660+ border-top: 1px solid var(--border);
661+}
662+
663+.markdown img {
664+ display: inline-block;
665+}
666+
667+.markdown table {
668+ display: block;
669+ max-width: 100%;
670+ overflow-x: auto;
671+ border-collapse: collapse;
672+}
673+
674+.markdown th,
675+.markdown td {
676+ padding: var(--space-sm) var(--space-md) var(--space-sm) 0;
677+ border-bottom: 1px solid var(--border);
678+}
679+
680+.markdown th {
681+ border-bottom-color: var(--border-visible);
682+ font-weight: 500;
683+ text-align: left;
684+}
685+
686+.markdown .markdown-alert {
687+ padding-left: var(--space-md);
688+ border-left: 2px solid var(--border-visible);
689+}
690+
691+.markdown .markdown-alert-warning,
692+.markdown .markdown-alert-caution {
693+ border-left-color: var(--accent);
694+}
695+
696+.markdown .markdown-alert-title {
697+ font-family: var(--font-mono);
698+ font-size: var(--label);
699+ letter-spacing: 0.08em;
700+ text-transform: uppercase;
701+ color: var(--text-secondary);
702+}
703+
704+.markdown .footnotes {
705+ font-size: var(--body-sm);
706+ color: var(--text-secondary);
707+ border-top: 1px solid var(--border);
708+ padding-top: var(--space-md);
709+}
A assets/css/syntax.css View
@@ -0,0 +1,25 @@
1+/*
2+ * Syntax highlighting: monochrome, by weight of gray. The classes come from
3+ * src/render/highlight.rs and are shared by the blob view and README code.
4+ */
5+
6+.hl-k {
7+ color: var(--text-display);
8+ font-weight: 700;
9+}
10+
11+.hl-d {
12+ color: var(--text-display);
13+}
14+
15+.hl-s {
16+ color: var(--text-secondary);
17+}
18+
19+.hl-c {
20+ color: var(--text-disabled);
21+}
22+
23+.hl-x {
24+ color: var(--accent);
25+}
A assets/css/tokens.css View
@@ -0,0 +1,94 @@
1+/*
2+ * Design tokens — Nothing design system.
3+ *
4+ * Every color is written once as light-dark(LIGHT, DARK). The active side is
5+ * picked by `color-scheme`: the OS preference by default, or forced by the
6+ * [data-theme] attribute set from the server config / theme toggle.
7+ */
8+
9+:root {
10+ color-scheme: light dark;
11+}
12+:root[data-theme="light"] {
13+ color-scheme: light;
14+}
15+:root[data-theme="dark"] {
16+ color-scheme: dark;
17+}
18+
19+:root {
20+ /* Surfaces: OLED black / printed-manual off-white. */
21+ --black: light-dark(#F5F5F5, #000000);
22+ --surface: light-dark(#FFFFFF, #111111);
23+ --surface-raised: light-dark(#F0F0F0, #1A1A1A);
24+ --border: light-dark(#E8E8E8, #222222);
25+ --border-visible: light-dark(#CCCCCC, #333333);
26+
27+ /* Text: the gray scale is the hierarchy. */
28+ --text-disabled: light-dark(#999999, #666666);
29+ --text-secondary: light-dark(#666666, #999999);
30+ --text-primary: light-dark(#1A1A1A, #E8E8E8);
31+ --text-display: light-dark(#000000, #FFFFFF);
32+
33+ /* Accent & status — identical in both modes. */
34+ --accent: #D71921;
35+ --accent-subtle: rgba(215, 25, 33, 0.15);
36+ --success: #4A9E5C;
37+ --warning: #D4A843;
38+ --error: #D71921;
39+ --info: var(--text-secondary);
40+ --interactive: light-dark(#007AFF, #5B9BF6);
41+
42+ /* Empty segment in segmented bars. */
43+ --segment-empty: light-dark(#E0E0E0, #222222);
44+
45+ /* Type families. --font-code may be overridden via ui.code_font. */
46+ --font-display: "Doto", "Space Mono", monospace;
47+ --font-body: "Space Grotesk", "DM Sans", system-ui, sans-serif;
48+ --font-mono: "Space Mono", "JetBrains Mono", "SF Mono", monospace;
49+ --font-code: var(--font-mono);
50+
51+ /* Type scale. */
52+ --display-xl: 72px;
53+ --display-lg: 48px;
54+ --display-md: 36px;
55+ --heading: 24px;
56+ --subheading: 18px;
57+ --body: 16px;
58+ --body-sm: 14px;
59+ --caption: 12px;
60+ --label: 11px;
61+
62+ /* Spacing (8px base). */
63+ --space-2xs: 2px;
64+ --space-xs: 4px;
65+ --space-sm: 8px;
66+ --space-md: 16px;
67+ --space-lg: 24px;
68+ --space-xl: 32px;
69+ --space-2xl: 48px;
70+ --space-3xl: 64px;
71+ --space-4xl: 96px;
72+
73+ /* Layout. */
74+ --gutter: var(--space-md);
75+ --page-max: 1200px;
76+ --rail: 280px;
77+
78+ /* Motion: subtle ease-out only. */
79+ --ease: cubic-bezier(0.25, 0.1, 0.25, 1);
80+ --dur-micro: 150ms;
81+ --dur: 300ms;
82+}
83+
84+@media (min-width: 768px) {
85+ :root {
86+ --gutter: var(--space-xl);
87+ }
88+}
89+
90+@media (min-width: 1200px) {
91+ :root {
92+ --gutter: var(--space-2xl);
93+ }
94+}
A assets/fonts/OFL.txt View
@@ -0,0 +1,98 @@
1+Fonts bundled with Ferrenium (woff2 subsets via Fontsource):
2+
3+Doto — Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto)
4+Space Grotesk — Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
5+Space Mono — Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
6+
7+
8+This Font Software is licensed under the SIL Open Font License, Version 1.1.
9+This license is copied below, and is also available with a FAQ at:
10+http://scripts.sil.org/OFL
11+
12+
13+-----------------------------------------------------------
14+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
15+-----------------------------------------------------------
16+
17+PREAMBLE
18+The goals of the Open Font License (OFL) are to stimulate worldwide
19+development of collaborative font projects, to support the font creation
20+efforts of academic and linguistic communities, and to provide a free and
21+open framework in which fonts may be shared and improved in partnership
22+with others.
23+
24+The OFL allows the licensed fonts to be used, studied, modified and
25+redistributed freely as long as they are not sold by themselves. The
26+fonts, including any derivative works, can be bundled, embedded,
27+redistributed and/or sold with any software provided that any reserved
28+names are not used by derivative works. The fonts and derivatives,
29+however, cannot be released under any other type of license. The
30+requirement for fonts to remain under this license does not apply
31+to any document created using the fonts or their derivatives.
32+
33+DEFINITIONS
34+"Font Software" refers to the set of files released by the Copyright
35+Holder(s) under this license and clearly marked as such. This may
36+include source files, build scripts and documentation.
37+
38+"Reserved Font Name" refers to any names specified as such after the
39+copyright statement(s).
40+
41+"Original Version" refers to the collection of Font Software components as
42+distributed by the Copyright Holder(s).
43+
44+"Modified Version" refers to any derivative made by adding to, deleting,
45+or substituting -- in part or in whole -- any of the components of the
46+Original Version, by changing formats or by porting the Font Software to a
47+new environment.
48+
49+"Author" refers to any designer, engineer, programmer, technical
50+writer or other person who contributed to the Font Software.
51+
52+PERMISSION & CONDITIONS
53+Permission is hereby granted, free of charge, to any person obtaining
54+a copy of the Font Software, to use, study, copy, merge, embed, modify,
55+redistribute, and sell modified and unmodified copies of the Font
56+Software, subject to the following conditions:
57+
58+1) Neither the Font Software nor any of its individual components,
59+in Original or Modified Versions, may be sold by itself.
60+
61+2) Original or Modified Versions of the Font Software may be bundled,
62+redistributed and/or sold with any software, provided that each copy
63+contains the above copyright notice and this license. These can be
64+included either as stand-alone text files, human-readable headers or
65+in the appropriate machine-readable metadata fields within text or
66+binary files as long as those fields can be easily viewed by the user.
67+
68+3) No Modified Version of the Font Software may use the Reserved Font
69+Name(s) unless explicit written permission is granted by the corresponding
70+Copyright Holder. This restriction only applies to the primary font name as
71+presented to the users.
72+
73+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
74+Software shall not be used to promote, endorse or advertise any
75+Modified Version, except to acknowledge the contribution(s) of the
76+Copyright Holder(s) and the Author(s) or with their explicit written
77+permission.
78+
79+5) The Font Software, modified or unmodified, in part or in whole,
80+must be distributed entirely under this license, and must not be
81+distributed under any other license. The requirement for fonts to
82+remain under this license does not apply to any document created
83+using the Font Software.
84+
85+TERMINATION
86+This license becomes null and void if any of the above conditions are
87+not met.
88+
89+DISCLAIMER
90+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
91+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
92+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
93+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
94+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
95+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
96+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
97+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
98+OTHER DEALINGS IN THE FONT SOFTWARE.
A assets/fonts/doto-latin-ext-wght.woff2 View

[Binary file]

A assets/fonts/doto-latin-wght.woff2 View

[Binary file]

A assets/fonts/space-grotesk-latin-ext-wght.woff2 View

[Binary file]

A assets/fonts/space-grotesk-latin-wght.woff2 View

[Binary file]

A assets/fonts/space-grotesk-vietnamese-wght.woff2 View

[Binary file]

A assets/fonts/space-mono-latin-400.woff2 View

[Binary file]

A assets/fonts/space-mono-latin-700.woff2 View

[Binary file]

A assets/fonts/space-mono-latin-ext-400.woff2 View

[Binary file]

A assets/fonts/space-mono-latin-ext-700.woff2 View

[Binary file]

A assets/fonts/space-mono-vietnamese-400.woff2 View

[Binary file]

A assets/fonts/space-mono-vietnamese-700.woff2 View

[Binary file]

A assets/icons/LICENSE-lucide.txt View
@@ -0,0 +1,15 @@
1+ISC License
2+
3+Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
4+
5+Permission to use, copy, modify, and/or distribute this software for any
6+purpose with or without fee is hereby granted, provided that the above
7+copyright notice and this permission notice appear in all copies.
8+
9+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
A assets/icons/favicon.svg View
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+<style>g{fill:#000}@media (prefers-color-scheme:dark){g{fill:#fff}}</style>
3+<g><circle cx="8" cy="4" r="1.6"/><circle cx="12" cy="4" r="1.6"/><circle cx="16" cy="4" r="1.6"/><circle cx="20" cy="4" r="1.6"/><circle cx="24" cy="4" r="1.6"/><circle cx="8" cy="8" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="8" cy="16" r="1.6"/><circle cx="12" cy="16" r="1.6"/><circle cx="16" cy="16" r="1.6"/><circle cx="20" cy="16" r="1.6"/><circle cx="8" cy="20" r="1.6"/><circle cx="8" cy="24" r="1.6"/><circle cx="8" cy="28" r="1.6"/></g>
4+<circle cx="24" cy="28" r="2" fill="#D71921"/>
5+</svg>
A assets/icons/sprite.svg View
@@ -0,0 +1,12 @@
1+<svg xmlns="http://www.w3.org/2000/svg">
2+<!-- Lucide icons (ISC license, see LICENSE-lucide.txt): 24px grid, 1.5px stroke, no fill. -->
3+<defs>
4+<symbol id="folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
5+<symbol id="file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol>
6+<symbol id="link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
7+<symbol id="package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
8+<symbol id="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
9+<symbol id="git-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
10+<symbol id="chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
11+</defs>
12+</svg>
A assets/js/app.js View
@@ -0,0 +1,129 @@
1+// Progressive enhancements. Everything works without this file.
2+(function () {
3+ "use strict";
4+
5+ // Index: instant filter; "/" focuses it, Escape clears it.
6+ var input = document.querySelector("[data-filter]");
7+ if (input) {
8+ var rows = Array.prototype.slice.call(document.querySelectorAll("[data-search]"));
9+ var sections = Array.prototype.slice.call(document.querySelectorAll("[data-section]"));
10+ var empty = document.querySelector("[data-filter-empty]");
11+
12+ var apply = function () {
13+ var terms = input.value.toLowerCase().split(/\s+/).filter(Boolean);
14+ var shown = 0;
15+ rows.forEach(function (row) {
16+ var text = row.getAttribute("data-search");
17+ var match = terms.every(function (t) { return text.indexOf(t) !== -1; });
18+ row.hidden = !match;
19+ if (match) shown++;
20+ });
21+ sections.forEach(function (section) {
22+ section.hidden = !section.querySelector("[data-search]:not([hidden])");
23+ });
24+ if (empty) empty.hidden = shown > 0;
25+ };
26+
27+ input.addEventListener("input", apply);
28+ input.addEventListener("keydown", function (e) {
29+ if (e.key === "Escape") {
30+ input.value = "";
31+ apply();
32+ input.blur();
33+ }
34+ });
35+ document.addEventListener("keydown", function (e) {
36+ if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return;
37+ var t = e.target;
38+ if (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
39+ e.preventDefault();
40+ input.focus();
41+ input.select();
42+ });
43+ }
44+ // Blob: line ranges. #L12 works without JS (:target); here shift-click
45+ // extends to #L12-L20 and the range is highlighted on load.
46+ var code = document.querySelector("[data-lines]");
47+ if (code) {
48+ var anchor = null;
49+ var select = function (from, to) {
50+ Array.prototype.forEach.call(code.querySelectorAll("tr.selected"), function (tr) {
51+ tr.classList.remove("selected");
52+ });
53+ if (!from) return;
54+ if (to < from) { var t = from; from = to; to = t; }
55+ for (var n = from; n <= to; n++) {
56+ var row = document.getElementById("L" + n);
57+ if (row) row.classList.add("selected");
58+ }
59+ };
60+ var fromHash = function () {
61+ var m = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);
62+ if (!m) return select(0);
63+ anchor = +m[1];
64+ select(anchor, +(m[2] || m[1]));
65+ if (m[2]) {
66+ var row = document.getElementById("L" + Math.min(+m[1], +m[2]));
67+ if (row) row.scrollIntoView({ block: "center" });
68+ }
69+ };
70+ code.addEventListener("click", function (e) {
71+ var a = e.target.closest && e.target.closest(".ln a");
72+ if (!a) return;
73+ var n = +a.textContent;
74+ e.preventDefault();
75+ var hash = "#L" + n;
76+ if (e.shiftKey && anchor && anchor !== n) {
77+ hash = "#L" + Math.min(anchor, n) + "-L" + Math.max(anchor, n);
78+ } else {
79+ anchor = n;
80+ }
81+ history.replaceState(null, "", hash);
82+ fromHash();
83+ });
84+ window.addEventListener("hashchange", fromHash);
85+ fromHash();
86+ }
87+
88+ // Summary: clone protocol switch and copy button.
89+ var clone = document.querySelector("[data-clone]");
90+ if (clone) {
91+ var KEY = "ferrenium-clone";
92+ var status = clone.querySelector("[data-copy-status]");
93+ var choose = function (i) {
94+ var rows = clone.querySelectorAll("[data-clone-url]");
95+ if (!rows[i]) i = 0;
96+ Array.prototype.forEach.call(rows, function (row, j) { row.hidden = j !== i; });
97+ Array.prototype.forEach.call(clone.querySelectorAll("[data-clone-choice]"), function (b, j) {
98+ b.setAttribute("aria-pressed", j === i ? "true" : "false");
99+ });
100+ };
101+ var saved = 0;
102+ try { saved = +(localStorage.getItem(KEY) || 0); } catch (e) {}
103+ choose(saved);
104+ clone.addEventListener("click", function (e) {
105+ var choice = e.target.closest("[data-clone-choice]");
106+ if (choice) {
107+ var i = +choice.getAttribute("data-clone-choice");
108+ choose(i);
109+ try { localStorage.setItem(KEY, i); } catch (err) {}
110+ return;
111+ }
112+ var copy = e.target.closest("[data-copy]");
113+ if (!copy) return;
114+ var input = copy.parentNode.querySelector("input");
115+ var done = function (ok) {
116+ status.textContent = ok ? "[Copied]" : "[Error: copy failed]";
117+ status.classList.toggle("status-error", !ok);
118+ clearTimeout(done.t);
119+ done.t = setTimeout(function () { status.textContent = ""; }, 2000);
120+ };
121+ if (navigator.clipboard) {
122+ navigator.clipboard.writeText(input.value).then(function () { done(true); }, function () { done(false); });
123+ } else {
124+ input.select();
125+ done(document.execCommand("copy"));
126+ }
127+ });
128+ }
129+})();
A assets/js/theme.js View
@@ -0,0 +1,45 @@
1+// Theme: runs synchronously in <head> so the stored choice applies before
2+// first paint. "auto" follows the OS; the server default comes from
3+// data-default-theme (ui.theme).
4+(function () {
5+ "use strict";
6+ var KEY = "ferrenium-theme";
7+ var root = document.documentElement;
8+ var fallback = root.getAttribute("data-default-theme") || "auto";
9+ var current = fallback;
10+
11+ try {
12+ current = localStorage.getItem(KEY) || fallback;
13+ } catch (e) {}
14+
15+ function apply(choice) {
16+ current = choice;
17+ if (choice === "light" || choice === "dark") {
18+ root.setAttribute("data-theme", choice);
19+ } else {
20+ root.removeAttribute("data-theme");
21+ }
22+ var buttons = document.querySelectorAll("[data-theme-choice]");
23+ for (var i = 0; i < buttons.length; i++) {
24+ var on = buttons[i].getAttribute("data-theme-choice") === choice;
25+ buttons[i].setAttribute("aria-pressed", on ? "true" : "false");
26+ }
27+ }
28+
29+ root.classList.add("js");
30+ apply(current);
31+
32+ document.addEventListener("DOMContentLoaded", function () {
33+ apply(current);
34+ document.addEventListener("click", function (event) {
35+ var button = event.target.closest && event.target.closest("[data-theme-choice]");
36+ if (!button) return;
37+ var choice = button.getAttribute("data-theme-choice");
38+ try {
39+ if (choice === fallback) localStorage.removeItem(KEY);
40+ else localStorage.setItem(KEY, choice);
41+ } catch (e) {}
42+ apply(choice);
43+ });
44+ });
45+})();
A dist/Caddyfile View
@@ -0,0 +1,5 @@
1+git.krolyxon.com {
2+ reverse_proxy 127.0.0.1:8080
3+ # or, with listen = "unix:/run/ferrenium/ferrenium.sock":
4+ # reverse_proxy unix//run/ferrenium/ferrenium.sock
5+}
A dist/ferrenium.service View
@@ -0,0 +1,43 @@
1+[Unit]
2+Description=Ferrenium git repository browser
3+After=network.target
4+
5+[Service]
6+Type=notify
7+ExecStart=/usr/local/bin/ferrenium serve --config /etc/ferrenium/ferrenium.toml
8+ExecReload=/bin/kill -HUP $MAINPID
9+Restart=on-failure
10+TimeoutStopSec=15
11+
12+User=git
13+Group=git
14+RuntimeDirectory=ferrenium
15+RuntimeDirectoryMode=0755
16+
17+# Read-only sandbox: ferrenium never writes to disk.
18+ProtectSystem=strict
19+ProtectHome=read-only
20+PrivateTmp=yes
21+PrivateDevices=yes
22+ProtectKernelTunables=yes
23+ProtectKernelModules=yes
24+ProtectKernelLogs=yes
25+ProtectControlGroups=yes
26+ProtectClock=yes
27+ProtectHostname=yes
28+ProtectProc=invisible
29+ProcSubset=pid
30+NoNewPrivileges=yes
31+CapabilityBoundingSet=
32+AmbientCapabilities=
33+RestrictNamespaces=yes
34+RestrictRealtime=yes
35+RestrictSUIDSGID=yes
36+LockPersonality=yes
37+MemoryDenyWriteExecute=yes
38+SystemCallArchitectures=native
39+SystemCallFilter=@system-service
40+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
41+
42+[Install]
43+WantedBy=multi-user.target
A dist/ferrenium.toml View
@@ -0,0 +1,60 @@
1+# Ferrenium configuration. Every key is optional; the values shown are the
2+# defaults unless noted. Unknown keys are an error — run `ferrenium check`.
3+# Relative paths are resolved against the directory of this file.
4+
5+[server]
6+listen = "127.0.0.1:8080" # or "unix:/run/ferrenium/ferrenium.sock"
7+base_url = "https://git.krolyxon.com" # used for absolute URLs (feeds, clone URLs); default: unset
8+base_path = "/" # serve under a sub-path, e.g. "/git/"
9+trusted_proxy = true # log X-Forwarded-For; default: false
10+compression = true # gzip + brotli
11+
12+[site]
13+title = "krolyxon" # default: "ferrenium"
14+description = "Personal git repositories"
15+clone_urls = ["https://git.krolyxon.com/{name}", "git@git.krolyxon.com:{name}"]
16+# custom_css = "custom.css"
17+# header = "header.html"
18+# footer = "footer.html"
19+
20+[repos]
21+scan_path = "/home/git"
22+max_depth = 3
23+follow_symlinks = false
24+section_from_path = false
25+hide = [] # globs over repo names, e.g. ["private/*"]
26+snapshots = ["tar.gz", "zip"]
27+https_clone = true
28+
29+# Optional per-repo overrides (take precedence over git config and the
30+# repository's description file).
31+# [[repo]]
32+# name = "dotfiles"
33+# description = "My dotfiles"
34+# section = "config"
35+# owner = "krolyxon"
36+# hidden = false
37+# readme = "docs/README.md"
38+# default_branch = "main"
39+
40+[ui]
41+theme = "auto" # auto | dark | light
42+code_font = "Space Mono"
43+max_blob_kb = 1024
44+max_highlight_kb = 256
45+
46+[cache]
47+size_mb = 64
48+
49+[watch]
50+enabled = true
51+debounce_ms = 500
52+poll_interval_secs = 300 # 0 disables the periodic full rescan
53+
54+[limits]
55+git_parallel = 0 # 0 = 2 × CPUs
56+heavy_parallel = 2
57+request_timeout_secs = 30
58+
59+[log]
60+level = "info" # RUST_LOG takes precedence when set
A src/cache.rs View
@@ -0,0 +1,143 @@
1+//! One byte-weighted in-memory cache for rendered and computed data.
2+//!
3+//! Keys are content-addressed (blob/tree/commit ids) and never need
4+//! invalidation. Ref-dependent entries include the repository's
5+//! `generation`, so a refresh retires them naturally.
6+
7+use std::sync::Arc;
8+
9+use crate::git::ObjectId;
10+use crate::git::refs::RefList;
11+use crate::git::tree::TreeEntry;
12+
13+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14+pub enum Key {
15+ /// Highlighted lines of a blob with a given syntax.
16+ Highlight {
17+ blob: ObjectId,
18+ syntax: String,
19+ },
20+ /// Rendered Markdown; `context` is everything relative links depend on.
21+ Markdown {
22+ blob: ObjectId,
23+ context: String,
24+ },
25+ Tree(ObjectId),
26+ CommitCount(ObjectId),
27+ Refs {
28+ repo: String,
29+ generation: u64,
30+ },
31+}
32+
33+#[derive(Clone)]
34+pub enum Value {
35+ Lines(Arc<Vec<String>>),
36+ Html(Arc<str>),
37+ Tree(Arc<Vec<TreeEntry>>),
38+ Count(u64),
39+ Refs(Arc<RefList>),
40+}
41+
42+impl Value {
43+ fn weight(&self) -> usize {
44+ match self {
45+ Value::Lines(lines) => lines.iter().map(|l| l.len() + 24).sum(),
46+ Value::Html(html) => html.len(),
47+ Value::Tree(entries) => entries.iter().map(|e| e.name.len() + 64).sum(),
48+ Value::Count(_) => 8,
49+ Value::Refs(refs) => refs.weight(),
50+ }
51+ }
52+}
53+
54+pub struct Cache {
55+ inner: moka::sync::Cache<Key, Value>,
56+}
57+
58+impl Cache {
59+ pub fn new(size_mb: u64) -> Self {
60+ let inner = moka::sync::Cache::builder()
61+ .max_capacity(size_mb.saturating_mul(1024 * 1024))
62+ .weigher(|key: &Key, value: &Value| {
63+ let key = match key {
64+ Key::Highlight { syntax, .. } => syntax.len(),
65+ Key::Markdown { context, .. } => context.len(),
66+ Key::Refs { repo, .. } => repo.len(),
67+ _ => 0,
68+ };
69+ u32::try_from(64 + key + value.weight()).unwrap_or(u32::MAX)
70+ })
71+ .build();
72+ Cache { inner }
73+ }
74+
75+ /// Cached value for `key`, computing (and storing) it on a miss.
76+ /// Errors are not cached.
77+ fn get_or<E>(&self, key: Key, compute: impl FnOnce() -> Result<Value, E>) -> Result<Value, E> {
78+ if let Some(value) = self.inner.get(&key) {
79+ return Ok(value);
80+ }
81+ let value = compute()?;
82+ self.inner.insert(key, value.clone());
83+ Ok(value)
84+ }
85+
86+ pub fn lines<E>(
87+ &self,
88+ key: Key,
89+ compute: impl FnOnce() -> Result<Vec<String>, E>,
90+ ) -> Result<Arc<Vec<String>>, E> {
91+ match self.get_or(key, || compute().map(|v| Value::Lines(Arc::new(v))))? {
92+ Value::Lines(v) => Ok(v),
93+ _ => unreachable!("cache key/value kind mismatch"),
94+ }
95+ }
96+
97+ pub fn html<E>(
98+ &self,
99+ key: Key,
100+ compute: impl FnOnce() -> Result<String, E>,
101+ ) -> Result<Arc<str>, E> {
102+ match self.get_or(key, || compute().map(|v| Value::Html(v.into())))? {
103+ Value::Html(v) => Ok(v),
104+ _ => unreachable!("cache key/value kind mismatch"),
105+ }
106+ }
107+
108+ pub fn tree<E>(
109+ &self,
110+ tree: ObjectId,
111+ compute: impl FnOnce() -> Result<Vec<TreeEntry>, E>,
112+ ) -> Result<Arc<Vec<TreeEntry>>, E> {
113+ match self.get_or(Key::Tree(tree), || {
114+ compute().map(|v| Value::Tree(Arc::new(v)))
115+ })? {
116+ Value::Tree(v) => Ok(v),
117+ _ => unreachable!("cache key/value kind mismatch"),
118+ }
119+ }
120+
121+ pub fn count<E>(&self, key: Key, compute: impl FnOnce() -> Result<u64, E>) -> Result<u64, E> {
122+ match self.get_or(key, || compute().map(Value::Count))? {
123+ Value::Count(v) => Ok(v),
124+ _ => unreachable!("cache key/value kind mismatch"),
125+ }
126+ }
127+
128+ pub fn refs<E>(
129+ &self,
130+ key: Key,
131+ compute: impl FnOnce() -> Result<RefList, E>,
132+ ) -> Result<Arc<RefList>, E> {
133+ match self.get_or(key, || compute().map(|v| Value::Refs(Arc::new(v))))? {
134+ Value::Refs(v) => Ok(v),
135+ _ => unreachable!("cache key/value kind mismatch"),
136+ }
137+ }
138+
139+ pub fn entry_count(&self) -> u64 {
140+ self.inner.run_pending_tasks();
141+ self.inner.entry_count()
142+ }
143+}
A src/config.rs View
@@ -0,0 +1,643 @@
1+//! Configuration schema, defaults, validation and loading.
2+//!
3+//! Every key is optional. Unknown keys are rejected so typos surface at load
4+//! time instead of being silently ignored.
5+
6+use std::fmt;
7+use std::net::SocketAddr;
8+use std::path::{Path, PathBuf};
9+use std::str::FromStr;
10+use std::sync::Arc;
11+
12+use serde::Deserialize;
13+
14+/// Well-known config location used in production.
15+pub const SYSTEM_CONFIG: &str = "/etc/ferrenium/ferrenium.toml";
16+
17+#[derive(Debug, thiserror::Error)]
18+pub enum ConfigError {
19+ #[error("cannot read {path}: {source}")]
20+ Io {
21+ path: PathBuf,
22+ source: std::io::Error,
23+ },
24+ #[error("{path}: {message}")]
25+ Parse { path: PathBuf, message: String },
26+ #[error("invalid configuration:\n - {}", .0.join("\n - "))]
27+ Invalid(Vec<String>),
28+}
29+
30+#[derive(Debug, Clone, Default, Deserialize)]
31+#[serde(deny_unknown_fields, default)]
32+pub struct Config {
33+ pub server: ServerConfig,
34+ pub site: SiteConfig,
35+ pub repos: ReposConfig,
36+ /// `[[repo]]` per-repository overrides.
37+ #[serde(rename = "repo")]
38+ pub repo_overrides: Vec<RepoOverride>,
39+ pub ui: UiConfig,
40+ pub cache: CacheConfig,
41+ pub watch: WatchConfig,
42+ pub limits: LimitsConfig,
43+ pub log: LogConfig,
44+
45+ /// File the config was loaded from (`None` = built-in defaults).
46+ #[serde(skip)]
47+ pub source: Option<PathBuf>,
48+ /// Contents of `custom_css` / `header` / `footer`, read at load time so
49+ /// that a reload picks up edits and requests never touch the filesystem.
50+ #[serde(skip)]
51+ pub custom: Customization,
52+}
53+
54+#[derive(Debug, Clone, Default)]
55+pub struct Customization {
56+ pub css: Option<Arc<str>>,
57+ pub header: Option<Arc<str>>,
58+ pub footer: Option<Arc<str>>,
59+}
60+
61+#[derive(Debug, Clone, Deserialize)]
62+#[serde(deny_unknown_fields, default)]
63+pub struct ServerConfig {
64+ pub listen: Listen,
65+ pub base_url: Option<String>,
66+ pub base_path: String,
67+ pub trusted_proxy: bool,
68+ pub compression: bool,
69+}
70+
71+impl Default for ServerConfig {
72+ fn default() -> Self {
73+ Self {
74+ listen: Listen::Tcp(SocketAddr::from(([127, 0, 0, 1], 8080))),
75+ base_url: None,
76+ base_path: "/".into(),
77+ trusted_proxy: false,
78+ compression: true,
79+ }
80+ }
81+}
82+
83+#[derive(Debug, Clone, Deserialize)]
84+#[serde(deny_unknown_fields, default)]
85+pub struct SiteConfig {
86+ pub title: String,
87+ pub description: String,
88+ pub clone_urls: Vec<String>,
89+ pub custom_css: Option<PathBuf>,
90+ pub header: Option<PathBuf>,
91+ pub footer: Option<PathBuf>,
92+}
93+
94+impl Default for SiteConfig {
95+ fn default() -> Self {
96+ Self {
97+ title: "ferrenium".into(),
98+ description: String::new(),
99+ clone_urls: Vec::new(),
100+ custom_css: None,
101+ header: None,
102+ footer: None,
103+ }
104+ }
105+}
106+
107+#[derive(Debug, Clone, Deserialize)]
108+#[serde(deny_unknown_fields, default)]
109+pub struct ReposConfig {
110+ pub scan_path: PathBuf,
111+ pub max_depth: u32,
112+ pub follow_symlinks: bool,
113+ pub section_from_path: bool,
114+ pub hide: Vec<String>,
115+ pub snapshots: Vec<SnapshotFormat>,
116+ pub https_clone: bool,
117+}
118+
119+impl Default for ReposConfig {
120+ fn default() -> Self {
121+ Self {
122+ scan_path: "/home/git".into(),
123+ max_depth: 3,
124+ follow_symlinks: false,
125+ section_from_path: false,
126+ hide: Vec::new(),
127+ snapshots: vec![SnapshotFormat::TarGz, SnapshotFormat::Zip],
128+ https_clone: true,
129+ }
130+ }
131+}
132+
133+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
134+pub enum SnapshotFormat {
135+ #[serde(rename = "tar.gz")]
136+ TarGz,
137+ #[serde(rename = "zip")]
138+ Zip,
139+}
140+
141+#[derive(Debug, Clone, Default, Deserialize)]
142+#[serde(deny_unknown_fields)]
143+pub struct RepoOverride {
144+ pub name: String,
145+ pub description: Option<String>,
146+ pub owner: Option<String>,
147+ pub section: Option<String>,
148+ pub hidden: Option<bool>,
149+ pub readme: Option<String>,
150+ pub default_branch: Option<String>,
151+}
152+
153+#[derive(Debug, Clone, Deserialize)]
154+#[serde(deny_unknown_fields, default)]
155+pub struct UiConfig {
156+ pub theme: Theme,
157+ pub code_font: String,
158+ pub max_blob_kb: u64,
159+ pub max_highlight_kb: u64,
160+}
161+
162+impl Default for UiConfig {
163+ fn default() -> Self {
164+ Self {
165+ theme: Theme::Auto,
166+ code_font: DEFAULT_CODE_FONT.into(),
167+ max_blob_kb: 1024,
168+ max_highlight_kb: 256,
169+ }
170+ }
171+}
172+
173+pub const DEFAULT_CODE_FONT: &str = "Space Mono";
174+
175+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
176+#[serde(rename_all = "lowercase")]
177+pub enum Theme {
178+ #[default]
179+ Auto,
180+ Dark,
181+ Light,
182+}
183+
184+impl Theme {
185+ pub fn as_str(self) -> &'static str {
186+ match self {
187+ Theme::Auto => "auto",
188+ Theme::Dark => "dark",
189+ Theme::Light => "light",
190+ }
191+ }
192+}
193+
194+#[derive(Debug, Clone, Deserialize)]
195+#[serde(deny_unknown_fields, default)]
196+pub struct CacheConfig {
197+ pub size_mb: u64,
198+}
199+
200+impl Default for CacheConfig {
201+ fn default() -> Self {
202+ Self { size_mb: 64 }
203+ }
204+}
205+
206+#[derive(Debug, Clone, Deserialize)]
207+#[serde(deny_unknown_fields, default)]
208+pub struct WatchConfig {
209+ pub enabled: bool,
210+ pub debounce_ms: u64,
211+ /// Full rescan interval; 0 disables polling.
212+ pub poll_interval_secs: u64,
213+}
214+
215+impl Default for WatchConfig {
216+ fn default() -> Self {
217+ Self {
218+ enabled: true,
219+ debounce_ms: 500,
220+ poll_interval_secs: 300,
221+ }
222+ }
223+}
224+
225+#[derive(Debug, Clone, Deserialize)]
226+#[serde(deny_unknown_fields, default)]
227+pub struct LimitsConfig {
228+ /// 0 = 2 × available CPUs.
229+ pub git_parallel: usize,
230+ pub heavy_parallel: usize,
231+ pub request_timeout_secs: u64,
232+}
233+
234+impl Default for LimitsConfig {
235+ fn default() -> Self {
236+ Self {
237+ git_parallel: 0,
238+ heavy_parallel: 2,
239+ request_timeout_secs: 30,
240+ }
241+ }
242+}
243+
244+#[derive(Debug, Clone, Deserialize)]
245+#[serde(deny_unknown_fields, default)]
246+pub struct LogConfig {
247+ pub level: String,
248+}
249+
250+impl Default for LogConfig {
251+ fn default() -> Self {
252+ Self {
253+ level: "info".into(),
254+ }
255+ }
256+}
257+
258+/// Where the server listens: a TCP socket address or `unix:/abs/path`.
259+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
260+#[serde(try_from = "String")]
261+pub enum Listen {
262+ Tcp(SocketAddr),
263+ Unix(PathBuf),
264+}
265+
266+impl FromStr for Listen {
267+ type Err = String;
268+
269+ fn from_str(s: &str) -> Result<Self, Self::Err> {
270+ if let Some(path) = s.strip_prefix("unix:") {
271+ let path = PathBuf::from(path);
272+ if !path.is_absolute() {
273+ return Err(format!("unix socket path must be absolute: {s:?}"));
274+ }
275+ return Ok(Listen::Unix(path));
276+ }
277+ s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| {
278+ format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)")
279+ })
280+ }
281+}
282+
283+impl TryFrom<String> for Listen {
284+ type Error = String;
285+
286+ fn try_from(s: String) -> Result<Self, Self::Error> {
287+ s.parse()
288+ }
289+}
290+
291+impl fmt::Display for Listen {
292+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293+ match self {
294+ Listen::Tcp(addr) => write!(f, "http://{addr}"),
295+ Listen::Unix(path) => write!(f, "unix:{}", path.display()),
296+ }
297+ }
298+}
299+
300+/// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the
301+/// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`.
302+///
303+/// Returns `None` when no explicit path was given and no file exists, in which
304+/// case the built-in defaults apply.
305+pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> {
306+ if let Some(path) = explicit {
307+ return Some(path.to_path_buf());
308+ }
309+ let system = PathBuf::from(SYSTEM_CONFIG);
310+ if system.is_file() {
311+ return Some(system);
312+ }
313+ let xdg = std::env::var_os("XDG_CONFIG_HOME")
314+ .map(PathBuf::from)
315+ .filter(|p| p.is_absolute())
316+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
317+ let user = xdg.join("ferrenium/ferrenium.toml");
318+ user.is_file().then_some(user)
319+}
320+
321+impl Config {
322+ /// Load, normalize and validate the config at `path` (or the defaults).
323+ pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
324+ let mut config = match path {
325+ Some(path) => {
326+ let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
327+ path: path.to_path_buf(),
328+ source,
329+ })?;
330+ let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse {
331+ path: path.to_path_buf(),
332+ message,
333+ })?;
334+ config.source = Some(path.to_path_buf());
335+ config
336+ }
337+ None => Config::default(),
338+ };
339+ config.finish()?;
340+ Ok(config)
341+ }
342+
343+ /// Parse TOML without validation. Errors carry line/column and a snippet.
344+ pub fn parse(text: &str) -> Result<Config, String> {
345+ toml::from_str(text).map_err(|e| e.to_string())
346+ }
347+
348+ /// Directory that relative paths in the config are resolved against.
349+ pub fn base_dir(&self) -> PathBuf {
350+ match self.source.as_deref().and_then(Path::parent) {
351+ Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
352+ _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
353+ }
354+ }
355+
356+ /// Normalize values, resolve relative paths, read customization files and
357+ /// validate. All problems are reported together.
358+ pub fn finish(&mut self) -> Result<(), ConfigError> {
359+ let mut errors = Vec::new();
360+ let base_dir = self.base_dir();
361+
362+ // server
363+ let bp = self.server.base_path.trim();
364+ if !bp.starts_with('/') {
365+ errors.push(format!("server.base_path must start with '/': {bp:?}"));
366+ } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") {
367+ errors.push(format!("server.base_path is not a clean path: {bp:?}"));
368+ }
369+ let mut bp = bp.trim_end_matches('/').to_string();
370+ bp.push('/');
371+ self.server.base_path = bp;
372+
373+ if let Some(url) = &mut self.server.base_url {
374+ let trimmed = url.trim().trim_end_matches('/').to_string();
375+ if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
376+ errors.push(format!(
377+ "server.base_url must start with http:// or https://: {url:?}"
378+ ));
379+ }
380+ *url = trimmed;
381+ }
382+
383+ // site
384+ if self.site.title.trim().is_empty() {
385+ errors.push("site.title must not be empty".into());
386+ }
387+ for (i, url) in self.site.clone_urls.iter().enumerate() {
388+ if !url.contains("{name}") {
389+ errors.push(format!(
390+ "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}"
391+ ));
392+ }
393+ }
394+ self.custom = Customization {
395+ css: read_custom(
396+ &base_dir,
397+ "site.custom_css",
398+ &mut self.site.custom_css,
399+ &mut errors,
400+ ),
401+ header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors),
402+ footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors),
403+ };
404+
405+ // repos
406+ if self.repos.scan_path.is_relative() {
407+ self.repos.scan_path = base_dir.join(&self.repos.scan_path);
408+ }
409+ if !(1..=16).contains(&self.repos.max_depth) {
410+ errors.push(format!(
411+ "repos.max_depth must be between 1 and 16, got {}",
412+ self.repos.max_depth
413+ ));
414+ }
415+ let mut seen = std::collections::HashSet::new();
416+ for (i, repo) in self.repo_overrides.iter_mut().enumerate() {
417+ let name = repo.name.trim().trim_matches('/').trim_end_matches(".git");
418+ if name.is_empty() {
419+ errors.push(format!("[[repo]] #{} has an empty name", i + 1));
420+ continue;
421+ }
422+ repo.name = name.to_string();
423+ if !seen.insert(repo.name.clone()) {
424+ errors.push(format!(
425+ "[[repo]] {:?} is defined more than once",
426+ repo.name
427+ ));
428+ }
429+ }
430+
431+ // ui
432+ if self.ui.code_font.trim().is_empty() {
433+ errors.push("ui.code_font must not be empty".into());
434+ } else if self
435+ .ui
436+ .code_font
437+ .chars()
438+ .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\'))
439+ {
440+ errors.push(format!(
441+ "ui.code_font contains characters not allowed in a font name: {:?}",
442+ self.ui.code_font
443+ ));
444+ }
445+ if self.ui.max_blob_kb == 0 {
446+ errors.push("ui.max_blob_kb must be greater than 0".into());
447+ }
448+
449+ // cache / watch / limits
450+ if self.cache.size_mb == 0 {
451+ errors.push("cache.size_mb must be greater than 0".into());
452+ }
453+ if self.watch.debounce_ms > 60_000 {
454+ errors.push("watch.debounce_ms must be at most 60000".into());
455+ }
456+ if self.limits.heavy_parallel == 0 {
457+ errors.push("limits.heavy_parallel must be at least 1".into());
458+ }
459+ if self.limits.request_timeout_secs == 0 {
460+ errors.push("limits.request_timeout_secs must be at least 1".into());
461+ }
462+
463+ // log
464+ if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) {
465+ errors.push(format!("log.level {:?} is invalid: {e}", self.log.level));
466+ }
467+
468+ if errors.is_empty() {
469+ Ok(())
470+ } else {
471+ Err(ConfigError::Invalid(errors))
472+ }
473+ }
474+
475+ /// Effective number of concurrent git workers.
476+ pub fn git_parallel(&self) -> usize {
477+ match self.limits.git_parallel {
478+ 0 => std::thread::available_parallelism().map_or(4, |n| n.get() * 2),
479+ n => n,
480+ }
481+ }
482+}
483+
484+/// Resolve a customization file path against `base_dir` and read it.
485+fn read_custom(
486+ base_dir: &Path,
487+ key: &str,
488+ path: &mut Option<PathBuf>,
489+ errors: &mut Vec<String>,
490+) -> Option<Arc<str>> {
491+ let p = path.as_mut()?;
492+ if p.is_relative() {
493+ *p = base_dir.join(&*p);
494+ }
495+ match std::fs::read_to_string(&*p) {
496+ Ok(text) => Some(text.into()),
497+ Err(e) => {
498+ errors.push(format!("{key}: cannot read {}: {e}", p.display()));
499+ None
500+ }
501+ }
502+}
503+
504+#[cfg(test)]
505+mod tests {
506+ use super::*;
507+
508+ fn finish(text: &str) -> Result<Config, ConfigError> {
509+ let mut c = Config::parse(text).map_err(|message| ConfigError::Parse {
510+ path: "test.toml".into(),
511+ message,
512+ })?;
513+ c.finish()?;
514+ Ok(c)
515+ }
516+
517+ #[test]
518+ fn empty_config_uses_defaults() {
519+ let c = finish("").unwrap();
520+ assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap());
521+ assert_eq!(c.server.base_path, "/");
522+ assert_eq!(c.repos.scan_path, PathBuf::from("/home/git"));
523+ assert_eq!(c.repos.max_depth, 3);
524+ assert_eq!(c.ui.theme, Theme::Auto);
525+ assert_eq!(c.cache.size_mb, 64);
526+ assert_eq!(
527+ c.repos.snapshots,
528+ vec![SnapshotFormat::TarGz, SnapshotFormat::Zip]
529+ );
530+ assert!(c.git_parallel() >= 1);
531+ }
532+
533+ #[test]
534+ fn example_config_is_valid() {
535+ let c = finish(crate::examples::CONFIG).unwrap();
536+ assert_eq!(c.site.title, "krolyxon");
537+ }
538+
539+ #[test]
540+ fn unknown_keys_are_rejected_with_position() {
541+ let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err();
542+ let msg = err.to_string();
543+ assert!(msg.contains("unknown field `listn`"), "{msg}");
544+ assert!(msg.contains("line 3"), "{msg}");
545+
546+ let err = finish("[sever]\n").unwrap_err().to_string();
547+ assert!(err.contains("unknown field `sever`"), "{err}");
548+ }
549+
550+ #[test]
551+ fn listen_parsing() {
552+ assert_eq!(
553+ "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(),
554+ Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into()))
555+ );
556+ assert!("unix:relative.sock".parse::<Listen>().is_err());
557+ assert!("localhost".parse::<Listen>().is_err());
558+ assert_eq!(
559+ "[::1]:9000".parse::<Listen>(),
560+ Ok(Listen::Tcp("[::1]:9000".parse().unwrap()))
561+ );
562+ let err = finish("[server]\nlisten = \"nope\"\n")
563+ .unwrap_err()
564+ .to_string();
565+ assert!(err.contains("invalid listen address"), "{err}");
566+ }
567+
568+ #[test]
569+ fn base_path_and_url_are_normalized() {
570+ let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap();
571+ assert_eq!(c.server.base_path, "/git/");
572+ assert_eq!(c.server.base_url.as_deref(), Some("https://x.org"));
573+ assert_eq!(
574+ finish("[server]\nbase_path = \"/\"\n")
575+ .unwrap()
576+ .server
577+ .base_path,
578+ "/"
579+ );
580+ }
581+
582+ #[test]
583+ fn validation_reports_all_errors() {
584+ let err = finish(
585+ r#"
586+ [server]
587+ base_path = "git"
588+ base_url = "ftp://x"
589+ [repos]
590+ max_depth = 0
591+ [ui]
592+ theme = "dark"
593+ code_font = "x; } body { display: none"
594+ [cache]
595+ size_mb = 0
596+ [[repo]]
597+ name = "a.git"
598+ [[repo]]
599+ name = "a"
600+ "#,
601+ )
602+ .unwrap_err();
603+ let ConfigError::Invalid(errors) = err else {
604+ panic!("expected validation errors, got {err}");
605+ };
606+ let all = errors.join("\n");
607+ for needle in [
608+ "base_path",
609+ "base_url",
610+ "max_depth",
611+ "code_font",
612+ "size_mb",
613+ "defined more than once",
614+ ] {
615+ assert!(all.contains(needle), "missing {needle:?} in:\n{all}");
616+ }
617+ }
618+
619+ #[test]
620+ fn invalid_enum_values_are_rejected() {
621+ assert!(finish("[ui]\ntheme = \"purple\"\n").is_err());
622+ assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err());
623+ }
624+
625+ #[test]
626+ fn custom_files_resolve_relative_to_config() {
627+ let dir = tempfile::tempdir().unwrap();
628+ std::fs::write(dir.path().join("custom.css"), "body{}").unwrap();
629+ let path = dir.path().join("ferrenium.toml");
630+ std::fs::write(
631+ &path,
632+ "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n",
633+ )
634+ .unwrap();
635+ let c = Config::load(Some(&path)).unwrap();
636+ assert_eq!(c.custom.css.as_deref(), Some("body{}"));
637+ assert_eq!(c.repos.scan_path, dir.path().join("repos"));
638+
639+ std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap();
640+ let err = Config::load(Some(&path)).unwrap_err().to_string();
641+ assert!(err.contains("site.footer"), "{err}");
642+ }
643+}
A src/examples.rs View
@@ -0,0 +1,5 @@
1+//! Ready-to-use deployment templates, printed by `ferrenium example`.
2+
3+pub const CONFIG: &str = include_str!("../dist/ferrenium.toml");
4+pub const SYSTEMD: &str = include_str!("../dist/ferrenium.service");
5+pub const CADDY: &str = include_str!("../dist/Caddyfile");
A src/git/mod.rs View
@@ -0,0 +1,226 @@
1+//! All repository access goes through this module; nothing else touches gix.
2+
3+pub mod refs;
4+pub mod tree;
5+
6+use std::hash::{Hash, Hasher};
7+use std::path::Path;
8+
9+use anyhow::Context;
10+use gix::bstr::ByteSlice;
11+
12+pub use gix::ObjectId;
13+pub use gix::Repository as Handle;
14+pub use gix::ThreadSafeRepository;
15+
16+/// A handle for use on the current thread.
17+pub fn handle(repo: &ThreadSafeRepository) -> Handle {
18+ repo.to_thread_local()
19+}
20+
21+/// Open a repository by its git dir (the bare repo, or a worktree's `.git`).
22+///
23+/// Only the repository's own config is read: no system/global config and no
24+/// `GIT_*` environment, so behavior doesn't depend on who runs the server.
25+pub fn open(git_dir: &Path) -> anyhow::Result<ThreadSafeRepository> {
26+ if git_dir.join("reftable").is_dir() {
27+ anyhow::bail!("uses the reftable ref format, which is not supported");
28+ }
29+ let options = gix::open::Options::isolated().open_path_as_is(true);
30+ let repo = ThreadSafeRepository::open_opts(git_dir, options)
31+ .with_context(|| format!("cannot open {}", git_dir.display()))?;
32+ let repo_local = repo.to_thread_local();
33+ if let Some(storage) = repo_local.config_snapshot().string("extensions.refStorage")
34+ && storage != "files"
35+ {
36+ anyhow::bail!("uses the {storage} ref format, which is not supported");
37+ }
38+ Ok(repo)
39+}
40+
41+/// Repository metadata stored in its git config.
42+///
43+/// `ferrenium.*` keys win; `gitweb.*` and `cgit.*` are read for painless
44+/// migration from those tools.
45+#[derive(Debug, Default, Clone, PartialEq, Eq)]
46+pub struct ConfigMeta {
47+ pub description: Option<String>,
48+ pub owner: Option<String>,
49+ pub section: Option<String>,
50+ pub hidden: Option<bool>,
51+ pub readme: Option<String>,
52+ pub default_branch: Option<String>,
53+}
54+
55+pub fn config_meta(repo: &gix::Repository) -> ConfigMeta {
56+ let config = repo.config_snapshot();
57+ let string = |keys: &[&str]| {
58+ keys.iter().find_map(|key| {
59+ let value = config.string(*key)?;
60+ let value = value.to_str_lossy().trim().to_string();
61+ (!value.is_empty()).then_some(value)
62+ })
63+ };
64+ ConfigMeta {
65+ description: string(&["ferrenium.description", "gitweb.description", "cgit.desc"]),
66+ owner: string(&["ferrenium.owner", "gitweb.owner", "cgit.owner"]),
67+ section: string(&["ferrenium.section", "gitweb.category", "cgit.section"]),
68+ hidden: ["ferrenium.hidden", "cgit.hide"]
69+ .iter()
70+ .find_map(|key| config.boolean(*key)),
71+ // cgit allows `branch:path`; we always read from the default branch.
72+ readme: string(&["ferrenium.readme", "cgit.readme"])
73+ .map(|r| {
74+ r.rsplit_once(':')
75+ .map_or(r.clone(), |(_, path)| path.to_string())
76+ })
77+ .filter(|r| !r.is_empty()),
78+ default_branch: string(&["ferrenium.defaultBranch", "cgit.defbranch"]),
79+ }
80+}
81+
82+/// Short branch name HEAD points to (also when that branch is unborn).
83+pub fn head_branch(repo: &gix::Repository) -> Option<String> {
84+ let name = repo.head_name().ok()??;
85+ name.as_bstr()
86+ .strip_prefix(b"refs/heads/")
87+ .map(|b| b.to_str_lossy().into_owned())
88+}
89+
90+/// Counts and a fingerprint over all refs.
91+#[derive(Debug, Default, Clone, PartialEq, Eq)]
92+pub struct RefSummary {
93+ pub branches: usize,
94+ pub tags: usize,
95+ /// Newest committer time across branch tips (unix seconds).
96+ pub last_activity: Option<i64>,
97+ /// Changes whenever any ref (or HEAD) changes.
98+ pub fingerprint: u64,
99+}
100+
101+pub fn ref_summary(repo: &gix::Repository) -> anyhow::Result<RefSummary> {
102+ let mut summary = RefSummary::default();
103+ let mut hasher = std::hash::DefaultHasher::new();
104+ if let Ok(head) = repo.find_reference("HEAD") {
105+ format!("{:?}", head.target()).hash(&mut hasher);
106+ }
107+ let platform = repo.references().context("cannot read refs")?;
108+ for reference in platform.all().context("cannot read refs")? {
109+ let Ok(mut reference) = reference else {
110+ continue;
111+ };
112+ let name = reference.name().as_bstr().to_owned();
113+ name.hash(&mut hasher);
114+ format!("{:?}", reference.target()).hash(&mut hasher);
115+
116+ if name.starts_with(b"refs/heads/") {
117+ summary.branches += 1;
118+ if let Ok(commit) = reference.peel_to_commit()
119+ && let Ok(time) = commit.time()
120+ {
121+ summary.last_activity = summary.last_activity.max(Some(time.seconds));
122+ }
123+ } else if name.starts_with(b"refs/tags/") {
124+ summary.tags += 1;
125+ }
126+ }
127+ summary.fingerprint = hasher.finish();
128+ Ok(summary)
129+}
130+
131+/// Commit id a branch points to, if the branch exists.
132+pub fn branch_commit(repo: &gix::Repository, branch: &str) -> Option<ObjectId> {
133+ let mut reference = repo.find_reference(&format!("refs/heads/{branch}")).ok()?;
134+ Some(reference.peel_to_commit().ok()?.id)
135+}
136+
137+/// Names tried for the README, in order of preference (case-insensitive).
138+const README_NAMES: &[&str] = &[
139+ "readme.md",
140+ "readme.markdown",
141+ "readme",
142+ "readme.txt",
143+ "readme.rst",
144+ "readme.adoc",
145+ "readme.org",
146+];
147+
148+/// Locate the README blob in `commit`'s tree: `preferred` (a path) if given,
149+/// otherwise the best match among root entries.
150+pub fn find_readme(
151+ repo: &gix::Repository,
152+ commit: ObjectId,
153+ preferred: Option<&str>,
154+) -> Option<(String, ObjectId)> {
155+ let tree = repo.find_commit(commit).ok()?.tree().ok()?;
156+ if let Some(path) = preferred {
157+ let entry = tree.lookup_entry_by_path(path).ok()??;
158+ return entry
159+ .mode()
160+ .is_blob()
161+ .then(|| (path.to_string(), entry.object_id()));
162+ }
163+ let mut best: Option<(usize, String, ObjectId)> = None;
164+ for entry in tree.iter().flatten() {
165+ if !entry.mode().is_blob() {
166+ continue;
167+ }
168+ let name = entry.filename().to_str_lossy();
169+ let lower = name.to_ascii_lowercase();
170+ if let Some(rank) = README_NAMES.iter().position(|n| *n == lower)
171+ && best.as_ref().is_none_or(|(r, _, _)| rank < *r)
172+ {
173+ best = Some((rank, name.into_owned(), entry.oid().to_owned()));
174+ }
175+ }
176+ best.map(|(_, name, oid)| (name, oid))
177+}
178+
179+/// The parts of a commit shown in listings.
180+#[derive(Debug, Clone, PartialEq, Eq)]
181+pub struct CommitInfo {
182+ pub id: ObjectId,
183+ pub subject: String,
184+ pub author_name: String,
185+ pub author_email: String,
186+ /// Unix seconds.
187+ pub author_time: i64,
188+ pub committer_time: i64,
189+}
190+
191+impl CommitInfo {
192+ pub fn weight(&self) -> usize {
193+ 64 + self.subject.len() + self.author_name.len() + self.author_email.len()
194+ }
195+}
196+
197+pub fn commit_info(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitInfo> {
198+ let commit = repo.find_commit(id).context("commit not found")?;
199+ let decoded = commit.decode().context("malformed commit")?;
200+ let author = decoded.author().context("malformed author")?;
201+ let committer = decoded.committer().context("malformed committer")?;
202+ Ok(CommitInfo {
203+ id,
204+ subject: decoded
205+ .message()
206+ .summary()
207+ .to_str_lossy()
208+ .trim()
209+ .to_string(),
210+ author_name: author.name.to_str_lossy().trim().to_string(),
211+ author_email: author.email.to_str_lossy().trim().to_string(),
212+ author_time: author.seconds(),
213+ committer_time: committer.seconds(),
214+ })
215+}
216+
217+/// Number of commits reachable from `tip` (uses the commit-graph when the
218+/// repository has one).
219+pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64> {
220+ let mut count = 0;
221+ for info in repo.rev_walk([tip]).all()? {
222+ info?;
223+ count += 1;
224+ }
225+ Ok(count)
226+}
A src/git/refs.rs View
@@ -0,0 +1,167 @@
1+//! Resolving revisions from URLs and listing refs.
2+
3+use gix::bstr::{BStr, ByteSlice};
4+
5+use super::{CommitInfo, ObjectId, commit_info};
6+
7+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+pub enum RefKind {
9+ Branch,
10+ Tag,
11+ /// A (possibly abbreviated) commit id written in the URL.
12+ Commit,
13+}
14+
15+/// A revision named in a URL, resolved to a commit.
16+#[derive(Debug, Clone, PartialEq, Eq)]
17+pub struct Rev {
18+ /// The name as written in the URL (`main`, `feature/x`, `v1.0`, an oid).
19+ pub name: String,
20+ pub kind: RefKind,
21+ pub commit: ObjectId,
22+}
23+
24+impl Rev {
25+ /// URLs naming a full commit id always show the same content.
26+ pub fn is_immutable(&self) -> bool {
27+ self.kind == RefKind::Commit && self.name.len() == self.commit.kind().len_in_hex()
28+ }
29+}
30+
31+/// Resolve `name` to a commit: a branch, then a tag, then a hex commit id
32+/// (at least 4 digits).
33+pub fn resolve(repo: &gix::Repository, name: &str) -> Option<Rev> {
34+ if name.is_empty() || name.starts_with('-') {
35+ return None;
36+ }
37+ for (prefix, kind) in [
38+ ("refs/heads/", RefKind::Branch),
39+ ("refs/tags/", RefKind::Tag),
40+ ] {
41+ if let Ok(mut reference) = repo.find_reference(&format!("{prefix}{name}"))
42+ && let Ok(commit) = reference.peel_to_commit()
43+ {
44+ return Some(Rev {
45+ name: name.to_string(),
46+ kind,
47+ commit: commit.id,
48+ });
49+ }
50+ }
51+ if (4..=40).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_hexdigit()) {
52+ let id = repo.rev_parse_single(BStr::new(name)).ok()?;
53+ let commit = id.object().ok()?.peel_to_commit().ok()?;
54+ return Some(Rev {
55+ name: name.to_string(),
56+ kind: RefKind::Commit,
57+ commit: commit.id,
58+ });
59+ }
60+ None
61+}
62+
63+/// Resolve the longest prefix of `segments` that names a revision; the
64+/// remaining segments are the path. Ref names may contain `/`.
65+pub fn resolve_prefix<'a>(
66+ repo: &gix::Repository,
67+ segments: &'a [Vec<u8>],
68+) -> Option<(Rev, &'a [Vec<u8>])> {
69+ (1..=segments.len()).rev().find_map(|n| {
70+ let parts: Option<Vec<&str>> = segments[..n]
71+ .iter()
72+ .map(|s| std::str::from_utf8(s).ok())
73+ .collect();
74+ let rev = resolve(repo, &parts?.join("/"))?;
75+ Some((rev, &segments[n..]))
76+ })
77+}
78+
79+#[derive(Debug, Clone)]
80+pub struct RefEntry {
81+ /// Short name (`main`, `v1.0`).
82+ pub name: String,
83+ pub commit: ObjectId,
84+ pub info: CommitInfo,
85+ /// Tag message subject, for annotated tags.
86+ pub tag_subject: Option<String>,
87+}
88+
89+#[derive(Debug, Clone, Default)]
90+pub struct RefList {
91+ /// Newest commit first.
92+ pub branches: Vec<RefEntry>,
93+ /// Newest first.
94+ pub tags: Vec<RefEntry>,
95+}
96+
97+impl RefList {
98+ pub fn weight(&self) -> usize {
99+ let entry = |e: &RefEntry| {
100+ 64 + e.name.len() + e.info.weight() + e.tag_subject.as_ref().map_or(0, String::len)
101+ };
102+ self.branches.iter().chain(&self.tags).map(entry).sum()
103+ }
104+}
105+
106+/// Every branch and tag that points (possibly through tags) at a commit.
107+pub fn list(repo: &gix::Repository) -> anyhow::Result<RefList> {
108+ let mut list = RefList::default();
109+ let platform = repo.references()?;
110+ for reference in platform.all()? {
111+ let Ok(mut reference) = reference else {
112+ continue;
113+ };
114+ let full = reference.name().as_bstr().to_owned();
115+ let (name, is_tag) = if let Some(n) = full.strip_prefix(b"refs/heads/") {
116+ (n, false)
117+ } else if let Some(n) = full.strip_prefix(b"refs/tags/") {
118+ (n, true)
119+ } else {
120+ continue;
121+ };
122+ let Ok(name) = name.to_str() else {
123+ continue;
124+ };
125+ let tag_subject = is_tag
126+ .then(|| {
127+ let id = reference.target().try_id()?.to_owned();
128+ let tag = repo.find_object(id).ok()?.try_into_tag().ok()?;
129+ let tag = tag.decode().ok()?;
130+ let subject = tag
131+ .message
132+ .lines()
133+ .next()?
134+ .to_str_lossy()
135+ .trim()
136+ .to_string();
137+ (!subject.is_empty()).then_some(subject)
138+ })
139+ .flatten();
140+ let Ok(commit) = reference.peel_to_commit() else {
141+ continue;
142+ };
143+ let Ok(info) = commit_info(repo, commit.id) else {
144+ continue;
145+ };
146+ let entry = RefEntry {
147+ name: name.to_string(),
148+ commit: commit.id,
149+ info,
150+ tag_subject,
151+ };
152+ if is_tag {
153+ list.tags.push(entry);
154+ } else {
155+ list.branches.push(entry);
156+ }
157+ }
158+ let newest_first = |a: &RefEntry, b: &RefEntry| {
159+ b.info
160+ .committer_time
161+ .cmp(&a.info.committer_time)
162+ .then_with(|| a.name.cmp(&b.name))
163+ };
164+ list.branches.sort_by(newest_first);
165+ list.tags.sort_by(newest_first);
166+ Ok(list)
167+}
A src/git/tree.rs View
@@ -0,0 +1,125 @@
1+//! Trees and blobs at a commit.
2+
3+use std::ffi::OsStr;
4+use std::os::unix::ffi::OsStrExt;
5+use std::path::Path;
6+
7+use anyhow::Context;
8+use gix::bstr::{BString, ByteSlice};
9+use gix::object::tree::EntryKind as GixKind;
10+
11+use super::ObjectId;
12+
13+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+pub enum EntryKind {
15+ Tree,
16+ Blob,
17+ Executable,
18+ Symlink,
19+ Submodule,
20+}
21+
22+impl EntryKind {
23+ fn from_gix(kind: GixKind) -> Self {
24+ match kind {
25+ GixKind::Tree => EntryKind::Tree,
26+ GixKind::Blob => EntryKind::Blob,
27+ GixKind::BlobExecutable => EntryKind::Executable,
28+ GixKind::Link => EntryKind::Symlink,
29+ GixKind::Commit => EntryKind::Submodule,
30+ }
31+ }
32+
33+ pub fn is_blob(self) -> bool {
34+ matches!(
35+ self,
36+ EntryKind::Blob | EntryKind::Executable | EntryKind::Symlink
37+ )
38+ }
39+}
40+
41+#[derive(Debug, Clone)]
42+pub struct TreeEntry {
43+ pub name: BString,
44+ pub kind: EntryKind,
45+ pub oid: ObjectId,
46+ /// Blob size in bytes (blobs and symlinks only).
47+ pub size: Option<u64>,
48+}
49+
50+/// What a path inside a commit's tree points to.
51+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52+pub enum PathTarget {
53+ Tree(ObjectId),
54+ Blob(ObjectId, EntryKind),
55+ Submodule(ObjectId),
56+}
57+
58+fn as_path(bytes: &[u8]) -> &Path {
59+ Path::new(OsStr::from_bytes(bytes))
60+}
61+
62+/// Look up `path` (slash-separated, empty for the root) in `commit`'s tree.
63+pub fn lookup(
64+ repo: &gix::Repository,
65+ commit: ObjectId,
66+ path: &[u8],
67+) -> anyhow::Result<Option<PathTarget>> {
68+ let tree = repo
69+ .find_commit(commit)
70+ .context("commit not found")?
71+ .tree()
72+ .context("commit has no tree")?;
73+ if path.is_empty() {
74+ return Ok(Some(PathTarget::Tree(tree.id)));
75+ }
76+ let Some(entry) = tree.lookup_entry_by_path(as_path(path))? else {
77+ return Ok(None);
78+ };
79+ let oid = entry.object_id();
80+ Ok(Some(match EntryKind::from_gix(entry.mode().kind()) {
81+ EntryKind::Tree => PathTarget::Tree(oid),
82+ EntryKind::Submodule => PathTarget::Submodule(oid),
83+ kind => PathTarget::Blob(oid, kind),
84+ }))
85+}
86+
87+/// Entries of a tree: directories first, then files, each by name.
88+pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEntry>> {
89+ let tree = repo.find_tree(tree).context("tree not found")?;
90+ let mut entries = Vec::new();
91+ for entry in tree.iter() {
92+ let entry = entry?;
93+ let kind = EntryKind::from_gix(entry.mode().kind());
94+ let oid = entry.oid().to_owned();
95+ let size = kind
96+ .is_blob()
97+ .then(|| repo.find_header(oid).ok().map(|h| h.size()))
98+ .flatten();
99+ entries.push(TreeEntry {
100+ name: entry.filename().to_owned(),
101+ kind,
102+ oid,
103+ size,
104+ });
105+ }
106+ entries.sort_by(|a, b| {
107+ let dir = |e: &TreeEntry| e.kind != EntryKind::Tree;
108+ dir(a)
109+ .cmp(&dir(b))
110+ .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
111+ .then_with(|| a.name.cmp(&b.name))
112+ });
113+ Ok(entries)
114+}
115+
116+/// Full contents of a blob.
117+pub fn blob(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<Vec<u8>> {
118+ let mut blob = repo.find_blob(oid).context("blob not found")?;
119+ Ok(blob.take_data())
120+}
121+
122+/// Size of an object without reading its contents.
123+pub fn size(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<u64> {
124+ Ok(repo.find_header(oid)?.size())
125+}
A src/handlers/blob.rs View
@@ -0,0 +1,223 @@
1+//! `/{repo}/blob/{rev}/{path…}` — a file: highlighted code with line
2+//! anchors, rendered Markdown, inline images.
3+
4+use std::sync::Arc;
5+
6+use askama::Template;
7+use axum::http::StatusCode;
8+use axum::response::Response;
9+use gix::bstr::ByteSlice;
10+
11+use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers};
12+use super::{Layout, render};
13+use crate::cache::Key;
14+use crate::git::tree::{self, EntryKind, PathTarget};
15+use crate::render::markdown::{self, Links};
16+use crate::render::{encode_path, highlight, human_size};
17+
18+/// Extensions shown inline as images (served by the raw endpoint).
19+const IMAGES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"];
20+
21+pub enum Body {
22+ Code { lines: Arc<Vec<String>> },
23+ Markdown { html: Arc<str> },
24+ Image { src: String },
25+ Symlink { target: String, url: Option<String> },
26+ Binary,
27+ TooLarge,
28+ Empty,
29+}
30+
31+pub struct ViewToggle {
32+ pub preview_url: String,
33+ pub source_url: String,
34+ pub source: bool,
35+}
36+
37+#[derive(Template)]
38+#[template(path = "blob.html")]
39+pub struct BlobPage {
40+ pub layout: Layout,
41+ pub nav: RepoNav,
42+ pub crumbs: Vec<Crumb>,
43+ pub here: Option<String>,
44+ /// `412 lines`, `12.4 KB`, `Rust`.
45+ pub facts: Vec<String>,
46+ pub raw_url: String,
47+ pub download_url: String,
48+ pub toggle: Option<ViewToggle>,
49+ pub body: Body,
50+}
51+
52+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
53+ let (rev, path) = match ctx.resolve(segments) {
54+ Ok(found) => found,
55+ Err(res) => return *res,
56+ };
57+ let (oid, kind) = match tree::lookup(&ctx.git, rev.commit, &path) {
58+ Ok(Some(PathTarget::Blob(oid, kind))) => (oid, kind),
59+ Ok(Some(PathTarget::Tree(_))) => return redirect(&ctx.url("tree", &rev.name, &path)),
60+ Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"),
61+ Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)),
62+ Err(e) => return ctx.internal_error(e),
63+ };
64+ let source = ctx.query.view.as_deref() == Some("source");
65+ let etag = ctx.etag(("blob", oid, &rev.name, source));
66+ let immutable = rev.is_immutable();
67+ if let Some(res) = ctx.not_modified(&etag, immutable) {
68+ return res;
69+ }
70+ let size = match tree::size(&ctx.git, oid) {
71+ Ok(size) => size,
72+ Err(e) => return ctx.internal_error(e),
73+ };
74+
75+ let path_str = path.to_str_lossy().into_owned();
76+ let name = path_str.rsplit('/').next().unwrap_or(&path_str).to_string();
77+ let dir = path.rsplit_once_str("/").map_or(&b""[..], |(d, _)| d);
78+ let raw_url = ctx.url("raw", &rev.name, &path);
79+ let mut lines_fact = None;
80+ let mut language = None;
81+ let mut toggle = None;
82+ let ext = name
83+ .rsplit_once('.')
84+ .map(|(_, e)| e.to_ascii_lowercase())
85+ .unwrap_or_default();
86+
87+ let body = if size == 0 {
88+ Body::Empty
89+ } else if IMAGES.contains(&ext.as_str()) && kind != EntryKind::Symlink {
90+ Body::Image {
91+ src: raw_url.clone(),
92+ }
93+ } else if size > ctx.config.ui.max_blob_kb * 1024 {
94+ Body::TooLarge
95+ } else {
96+ let data = match tree::blob(&ctx.git, oid) {
97+ Ok(data) => data,
98+ Err(e) => return ctx.internal_error(e),
99+ };
100+ if kind == EntryKind::Symlink {
101+ let target = data.to_str_lossy().into_owned();
102+ let url = resolve_link(dir, &data).map(|p| ctx.url("blob", &rev.name, &p));
103+ Body::Symlink { target, url }
104+ } else if data[..data.len().min(8000)].contains(&0) {
105+ Body::Binary
106+ } else {
107+ let text = String::from_utf8_lossy(&data);
108+ lines_fact = Some(line_count(&text));
109+ let is_md = markdown::is_markdown(&name);
110+ if is_md {
111+ let base = ctx.url("blob", &rev.name, &path);
112+ toggle = Some(ViewToggle {
113+ source_url: format!("{base}?view=source"),
114+ preview_url: base,
115+ source,
116+ });
117+ }
118+ if is_md && !source {
119+ let links = Links {
120+ repo_url: ctx.repo_url(),
121+ rev: encode_path(&rev.name),
122+ dir: dir.to_vec(),
123+ };
124+ let key = Key::Markdown {
125+ blob: oid,
126+ context: links.key(),
127+ };
128+ let html = ctx.state.cache.html(key, || {
129+ Ok::<_, std::convert::Infallible>(markdown::render(&text, &links))
130+ });
131+ Body::Markdown {
132+ html: html.unwrap_or_else(|never| match never {}),
133+ }
134+ } else {
135+ let first_line = text.lines().next().unwrap_or_default();
136+ let syntax = highlight::detect(&path_str, first_line);
137+ language = syntax.map(|s| s.name.clone());
138+ let lines = match syntax {
139+ Some(syntax) if size <= ctx.config.ui.max_highlight_kb * 1024 => {
140+ let key = Key::Highlight {
141+ blob: oid,
142+ syntax: syntax.name.clone(),
143+ };
144+ ctx.state
145+ .cache
146+ .lines(key, || {
147+ Ok::<_, std::convert::Infallible>(highlight::lines(&text, syntax))
148+ })
149+ .unwrap_or_else(|never| match never {})
150+ }
151+ _ => Arc::new(highlight::plain_lines(&text)),
152+ };
153+ Body::Code { lines }
154+ }
155+ }
156+ };
157+ let facts = [lines_fact, Some(human_size(size)), language]
158+ .into_iter()
159+ .flatten()
160+ .collect();
161+
162+ let (crumbs, _) = crumbs(ctx, &rev.name, &path);
163+ let page = BlobPage {
164+ layout: Layout::new(
165+ ctx.state,
166+ ctx.config,
167+ Some(&format!("{path_str} · {}", ctx.repo.name)),
168+ ),
169+ nav: ctx.nav(Active::Tree, Some(&rev), |name| {
170+ ctx.url("blob", name, &path)
171+ }),
172+ crumbs,
173+ here: Some(name),
174+ facts,
175+ download_url: format!("{raw_url}?download"),
176+ raw_url,
177+ toggle,
178+ body,
179+ };
180+ let mut res = render(StatusCode::OK, &page);
181+ set_cache_headers(&mut res, &etag, immutable);
182+ res
183+}
184+
185+fn line_count(text: &str) -> String {
186+ let n = text.lines().count();
187+ format!("{n} {}", if n == 1 { "line" } else { "lines" })
188+}
189+
190+/// Resolve a relative symlink target against the link's directory. `None`
191+/// for absolute targets or ones leaving the repository.
192+fn resolve_link(dir: &[u8], target: &[u8]) -> Option<Vec<u8>> {
193+ if target.starts_with(b"/") {
194+ return None;
195+ }
196+ let mut parts: Vec<&[u8]> = dir
197+ .split(|b| *b == b'/')
198+ .filter(|p| !p.is_empty())
199+ .collect();
200+ for part in target.split(|b| *b == b'/') {
201+ match part {
202+ b"" | b"." => {}
203+ b".." => {
204+ parts.pop()?;
205+ }
206+ part => parts.push(part),
207+ }
208+ }
209+ Some(parts.join(&b'/'))
210+}
211+
212+#[cfg(test)]
213+mod tests {
214+ use super::*;
215+
216+ #[test]
217+ fn symlink_targets() {
218+ assert_eq!(resolve_link(b"a/b", b"../c.txt").unwrap(), b"a/c.txt");
219+ assert_eq!(resolve_link(b"", b"./x").unwrap(), b"x");
220+ assert!(resolve_link(b"", b"../x").is_none());
221+ assert!(resolve_link(b"a", b"/etc/passwd").is_none());
222+ }
223+}
A src/handlers/error.rs View
@@ -0,0 +1,34 @@
1+//! Error pages.
2+
3+use std::sync::Arc;
4+
5+use askama::Template;
6+use axum::extract::State;
7+use axum::http::StatusCode;
8+use axum::response::Response;
9+
10+use super::{Layout, render};
11+use crate::state::AppState;
12+
13+#[derive(Template)]
14+#[template(path = "error.html")]
15+pub struct ErrorPage {
16+ pub layout: Layout,
17+ pub code: u16,
18+ pub message: String,
19+}
20+
21+pub fn page(state: &AppState, status: StatusCode, message: impl Into<String>) -> Response {
22+ let config = state.config.load();
23+ let code = status.as_u16();
24+ let page = ErrorPage {
25+ layout: Layout::new(state, &config, Some(&code.to_string())),
26+ code,
27+ message: message.into(),
28+ };
29+ render(status, &page)
30+}
31+
32+pub async fn not_found(State(state): State<Arc<AppState>>) -> Response {
33+ page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND")
34+}
A src/handlers/index.rs View
@@ -0,0 +1,156 @@
1+//! `/` — repository index.
2+
3+use std::sync::Arc;
4+
5+use askama::Template;
6+use axum::extract::{Query, State};
7+use axum::http::StatusCode;
8+use axum::response::Response;
9+use serde::Deserialize;
10+
11+use super::{Layout, render};
12+use crate::index::Repo;
13+use crate::render::{encode_path, time};
14+use crate::state::AppState;
15+
16+/// Repos pushed within this window get the activity dot.
17+const FRESH_SECS: i64 = 24 * 3600;
18+
19+#[derive(Debug, Default, Deserialize)]
20+pub struct IndexQuery {
21+ #[serde(default)]
22+ q: String,
23+ #[serde(default)]
24+ sort: String,
25+}
26+
27+pub struct Row {
28+ pub name: String,
29+ pub url: String,
30+ pub description: Option<String>,
31+ pub age: Option<String>,
32+ pub iso: Option<String>,
33+ pub fresh: bool,
34+ /// Lowercased text the client-side filter matches against.
35+ pub search: String,
36+}
37+
38+pub struct Section {
39+ pub name: Option<String>,
40+ pub rows: Vec<Row>,
41+}
42+
43+#[derive(Template)]
44+#[template(path = "index.html")]
45+pub struct IndexPage {
46+ pub layout: Layout,
47+ pub description: String,
48+ /// Visible repositories in total.
49+ pub total: usize,
50+ /// Repositories matching the filter.
51+ pub shown: usize,
52+ pub q: String,
53+ pub by_activity: bool,
54+ pub sort_name_url: String,
55+ pub sort_activity_url: String,
56+ pub sections: Vec<Section>,
57+}
58+
59+pub async fn index(
60+ State(state): State<Arc<AppState>>,
61+ Query(query): Query<IndexQuery>,
62+) -> Response {
63+ let config = state.config.load();
64+ let index = state.index.load();
65+ let by_activity = query.sort == "activity";
66+ let q = query.q.trim().to_string();
67+ let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect();
68+ let now = time::now();
69+ let base = &config.server.base_path;
70+
71+ let mut repos: Vec<&Arc<Repo>> = index
72+ .repos
73+ .values()
74+ .filter(|repo| {
75+ let text = search_text(repo);
76+ terms.iter().all(|t| text.contains(t.as_str()))
77+ })
78+ .collect();
79+ let shown = repos.len();
80+
81+ if by_activity {
82+ repos.sort_by(|a, b| {
83+ b.meta
84+ .last_activity
85+ .cmp(&a.meta.last_activity)
86+ .then_with(|| a.name.cmp(&b.name))
87+ });
88+ } else {
89+ repos.sort_by_cached_key(|r| {
90+ (
91+ r.meta.section.clone().map(|s| s.to_lowercase()),
92+ r.name.to_lowercase(),
93+ )
94+ });
95+ }
96+
97+ let row = |repo: &Arc<Repo>| Row {
98+ name: repo.name.clone(),
99+ url: format!("{base}{}", encode_path(&repo.name)),
100+ description: repo.meta.description.clone(),
101+ age: repo.meta.last_activity.map(|t| time::relative(t, now)),
102+ iso: repo.meta.last_activity.map(time::iso),
103+ fresh: repo
104+ .meta
105+ .last_activity
106+ .is_some_and(|t| now - t < FRESH_SECS),
107+ search: search_text(repo),
108+ };
109+
110+ let mut sections: Vec<Section> = Vec::new();
111+ for repo in repos {
112+ // Sections only apply to the name ordering; activity is one list.
113+ let section = if by_activity {
114+ None
115+ } else {
116+ repo.meta.section.clone()
117+ };
118+ match sections.last_mut() {
119+ Some(last) if last.name == section => last.rows.push(row(repo)),
120+ _ => sections.push(Section {
121+ name: section,
122+ rows: vec![row(repo)],
123+ }),
124+ }
125+ }
126+
127+ let sort_url = |sort: &str| {
128+ let mut url = format!("{base}?sort={sort}");
129+ if !q.is_empty() {
130+ url.push_str("&q=");
131+ url.push_str(&encode_path(&q));
132+ }
133+ url
134+ };
135+ let page = IndexPage {
136+ layout: Layout::new(&state, &config, None),
137+ description: config.site.description.clone(),
138+ total: index.repos.len(),
139+ shown,
140+ sort_name_url: sort_url("name"),
141+ sort_activity_url: sort_url("activity"),
142+ q,
143+ by_activity,
144+ sections,
145+ };
146+ render(StatusCode::OK, &page)
147+}
148+
149+fn search_text(repo: &Repo) -> String {
150+ let mut text = repo.name.to_lowercase();
151+ if let Some(d) = &repo.meta.description {
152+ text.push(' ');
153+ text.push_str(&d.to_lowercase());
154+ }
155+ text
156+}
A src/handlers/mod.rs View
@@ -0,0 +1,71 @@
1+//! Request handlers. Each page is an askama template that embeds a [`Layout`].
2+
3+pub mod blob;
4+pub mod error;
5+pub mod index;
6+pub mod raw;
7+pub mod refs;
8+pub mod repo;
9+pub mod summary;
10+pub mod tree;
11+
12+use std::sync::Arc;
13+
14+use askama::Template;
15+use axum::http::StatusCode;
16+use axum::response::{Html, IntoResponse, Response};
17+
18+use crate::config::Config;
19+use crate::server::assets;
20+use crate::state::AppState;
21+
22+/// Data shared by every page: site chrome, asset URLs and customization.
23+pub struct Layout {
24+ /// `server.base_path`, always ending in `/`.
25+ pub base: String,
26+ /// `{base}-/static/{hash}/`
27+ pub static_base: String,
28+ pub site_title: String,
29+ /// Full `<title>` text.
30+ pub page_title: String,
31+ /// Server default theme: `auto`, `dark` or `light`.
32+ pub theme: &'static str,
33+ /// Versioned URL of `/-/site.css`, when there is anything in it.
34+ pub site_css: Option<String>,
35+ pub header: Option<Arc<str>>,
36+ pub footer: Option<Arc<str>>,
37+ pub version: &'static str,
38+}
39+
40+impl Layout {
41+ /// `title` is the page-specific part; the site title is appended.
42+ pub fn new(state: &AppState, config: &Config, title: Option<&str>) -> Self {
43+ let base = config.server.base_path.clone();
44+ let site_title = config.site.title.clone();
45+ Layout {
46+ static_base: format!("{base}-/static/{}/", state.assets.hash()),
47+ site_css: assets::site_css_version(config).map(|v| format!("{base}-/site.css?v={v}")),
48+ page_title: match title {
49+ Some(t) => format!("{t} · {site_title}"),
50+ None => site_title.clone(),
51+ },
52+ site_title,
53+ base,
54+ theme: config.ui.theme.as_str(),
55+ header: config.custom.header.clone(),
56+ footer: config.custom.footer.clone(),
57+ version: env!("CARGO_PKG_VERSION"),
58+ }
59+ }
60+}
61+
62+/// Render a template into an HTML response with the given status.
63+pub fn render(status: StatusCode, page: &impl Template) -> Response {
64+ match page.render() {
65+ Ok(html) => (status, Html(html)).into_response(),
66+ Err(e) => {
67+ tracing::error!("template error: {e}");
68+ (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response()
69+ }
70+ }
71+}
A src/handlers/raw.rs View
@@ -0,0 +1,102 @@
1+//! `/{repo}/raw/{rev}/{path…}` — file contents as stored.
2+//!
3+//! Nothing served here may run in our origin: text (HTML and SVG included)
4+//! is `text/plain`, only raster images keep their type, and every response
5+//! is sandboxed and `nosniff`. `?download` sends an attachment instead.
6+
7+use axum::body::Body;
8+use axum::http::{HeaderValue, StatusCode, header};
9+use axum::response::{IntoResponse, Response};
10+use gix::bstr::ByteSlice;
11+
12+use super::repo::{Ctx, set_cache_headers};
13+use crate::git::tree::{self, PathTarget};
14+use crate::render::encode_path;
15+
16+pub const RAW_CSP: &str = "default-src 'none'; img-src 'self'; sandbox";
17+
18+/// Image types safe to serve with their real content type.
19+const IMAGE_TYPES: &[(&str, &str)] = &[
20+ ("png", "image/png"),
21+ ("jpg", "image/jpeg"),
22+ ("jpeg", "image/jpeg"),
23+ ("gif", "image/gif"),
24+ ("webp", "image/webp"),
25+ ("avif", "image/avif"),
26+ ("bmp", "image/bmp"),
27+ ("ico", "image/x-icon"),
28+];
29+
30+pub fn serve(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
31+ let (rev, path) = match ctx.resolve(segments) {
32+ Ok(found) => found,
33+ Err(res) => return *res,
34+ };
35+ let oid = match tree::lookup(&ctx.git, rev.commit, &path) {
36+ Ok(Some(PathTarget::Blob(oid, _))) => oid,
37+ Ok(Some(_)) => return ctx.not_found("NOT A FILE"),
38+ Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)),
39+ Err(e) => return ctx.internal_error(e),
40+ };
41+ let download = ctx.query.download.is_some();
42+ let etag = format!("W/\"{oid}{}\"", if download { "-d" } else { "" });
43+ let immutable = rev.is_immutable();
44+ if let Some(res) = ctx.not_modified(&etag, immutable) {
45+ return res;
46+ }
47+ let data = match tree::blob(&ctx.git, oid) {
48+ Ok(data) => data,
49+ Err(e) => return ctx.internal_error(e),
50+ };
51+
52+ let name = path
53+ .rsplit_once_str("/")
54+ .map_or(&path[..], |(_, n)| n)
55+ .to_str_lossy()
56+ .into_owned();
57+ let ext = name
58+ .rsplit_once('.')
59+ .map(|(_, e)| e.to_ascii_lowercase())
60+ .unwrap_or_default();
61+ let is_text = !data[..data.len().min(8000)].contains(&0);
62+ let content_type = match IMAGE_TYPES.iter().find(|(e, _)| *e == ext) {
63+ Some((_, mime)) => mime,
64+ None if download => "application/octet-stream",
65+ None if is_text => "text/plain; charset=utf-8",
66+ None => "application/octet-stream",
67+ };
68+
69+ let mut res = Body::from(data).into_response();
70+ *res.status_mut() = StatusCode::OK;
71+ let headers = res.headers_mut();
72+ headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
73+ headers.insert(
74+ header::CONTENT_SECURITY_POLICY,
75+ HeaderValue::from_static(RAW_CSP),
76+ );
77+ headers.insert(
78+ header::X_CONTENT_TYPE_OPTIONS,
79+ HeaderValue::from_static("nosniff"),
80+ );
81+ if download {
82+ let ascii: String = name
83+ .chars()
84+ .map(|c| {
85+ if c.is_ascii_graphic() && c != '"' && c != '\\' {
86+ c
87+ } else {
88+ '_'
89+ }
90+ })
91+ .collect();
92+ let value = format!(
93+ "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}",
94+ encode_path(&name)
95+ );
96+ if let Ok(value) = HeaderValue::from_str(&value) {
97+ headers.insert(header::CONTENT_DISPOSITION, value);
98+ }
99+ }
100+ set_cache_headers(&mut res, &etag, immutable);
101+ res
102+}
A src/handlers/refs.rs View
@@ -0,0 +1,83 @@
1+//! `/{repo}/refs` — branches and tags.
2+
3+use askama::Template;
4+use axum::http::StatusCode;
5+use axum::response::Response;
6+
7+use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers};
8+use super::{Layout, render};
9+use crate::git::refs::RefEntry;
10+use crate::render::time;
11+
12+pub struct RefRow {
13+ pub name: String,
14+ pub url: String,
15+ pub subject: String,
16+ pub age: String,
17+ pub iso: String,
18+ pub is_default: bool,
19+}
20+
21+#[derive(Template)]
22+#[template(path = "refs.html")]
23+pub struct RefsPage {
24+ pub layout: Layout,
25+ pub nav: RepoNav,
26+ pub crumbs: Vec<Crumb>,
27+ pub here: Option<String>,
28+ pub branches: Vec<RefRow>,
29+ pub tags: Vec<RefRow>,
30+}
31+
32+pub fn page(ctx: &Ctx) -> Response {
33+ let etag = ctx.etag("refs");
34+ if let Some(res) = ctx.not_modified(&etag, false) {
35+ return res;
36+ }
37+ let refs = match ctx.refs() {
38+ Ok(refs) => refs,
39+ Err(e) => return ctx.internal_error(e),
40+ };
41+ let now = time::now();
42+ let default = ctx.repo.meta.default_branch.as_deref();
43+ let row = |r: &RefEntry| RefRow {
44+ name: r.name.clone(),
45+ url: ctx.url("tree", &r.name, b""),
46+ subject: r
47+ .tag_subject
48+ .clone()
49+ .unwrap_or_else(|| r.info.subject.clone()),
50+ age: time::relative(r.info.committer_time, now),
51+ iso: time::iso(r.info.committer_time),
52+ is_default: false,
53+ };
54+ let mut branches: Vec<RefRow> = refs.branches.iter().map(row).collect();
55+ // The default branch leads the list.
56+ if let Some(i) = branches
57+ .iter()
58+ .position(|b| Some(b.name.as_str()) == default)
59+ {
60+ let mut b = branches.remove(i);
61+ b.is_default = true;
62+ branches.insert(0, b);
63+ }
64+ let crumbs = vec![Crumb {
65+ name: ctx.repo.name.clone(),
66+ url: ctx.repo_url(),
67+ }];
68+ let page = RefsPage {
69+ layout: Layout::new(
70+ ctx.state,
71+ ctx.config,
72+ Some(&format!("Refs · {}", ctx.repo.name)),
73+ ),
74+ nav: ctx.nav(Active::Refs, None, |_| String::new()),
75+ crumbs,
76+ here: Some("Refs".into()),
77+ branches,
78+ tags: refs.tags.iter().map(row).collect(),
79+ };
80+ let mut res = render(StatusCode::OK, &page);
81+ set_cache_headers(&mut res, &etag, false);
82+ res
83+}
A src/handlers/repo.rs View
@@ -0,0 +1,306 @@
1+//! Shared plumbing for repository pages: routing by action, revision
2+//! resolution, ETags and the repository nav.
3+//!
4+//! Everything here runs on the blocking pool (see [`AppState::blocking`]).
5+
6+use std::hash::{Hash, Hasher};
7+use std::sync::Arc;
8+use std::sync::atomic::Ordering;
9+
10+use axum::http::{HeaderValue, StatusCode, header};
11+use axum::response::{IntoResponse, Redirect, Response};
12+use serde::Deserialize;
13+
14+use super::{blob, error, raw, refs, summary, tree};
15+use crate::cache::Key;
16+use crate::config::Config;
17+use crate::git::refs::{RefKind, RefList, Rev};
18+use crate::git::{self, Handle};
19+use crate::index::Repo;
20+use crate::render::{encode_bytes, encode_path};
21+use crate::state::AppState;
22+
23+/// Tags listed in the ref picker; the rest are on the refs page.
24+const PICKER_TAGS: usize = 20;
25+
26+#[derive(Debug, Default, Deserialize)]
27+pub struct PageQuery {
28+ /// Blob view: `source` shows Markdown as code.
29+ pub view: Option<String>,
30+ /// Raw: serve as an attachment.
31+ pub download: Option<String>,
32+}
33+
34+pub struct RepoRequest {
35+ pub repo: Arc<Repo>,
36+ /// Path segments after the repository name.
37+ pub segments: Vec<Vec<u8>>,
38+ pub query: PageQuery,
39+ pub if_none_match: Option<HeaderValue>,
40+ pub config: Arc<Config>,
41+}
42+
43+/// Everything a repository page handler needs.
44+pub struct Ctx<'a> {
45+ pub state: &'a AppState,
46+ pub config: &'a Config,
47+ pub repo: &'a Repo,
48+ pub git: Handle,
49+ pub query: &'a PageQuery,
50+ if_none_match: Option<&'a HeaderValue>,
51+}
52+
53+pub fn handle(state: &AppState, req: RepoRequest) -> Response {
54+ let ctx = Ctx {
55+ state,
56+ config: &req.config,
57+ repo: &req.repo,
58+ git: git::handle(&req.repo.gix),
59+ query: &req.query,
60+ if_none_match: req.if_none_match.as_ref(),
61+ };
62+ let Some((action, rest)) = req.segments.split_first() else {
63+ return summary::page(&ctx);
64+ };
65+ match action.as_slice() {
66+ b"tree" => tree::page(&ctx, rest),
67+ b"blob" => blob::page(&ctx, rest),
68+ b"raw" => raw::serve(&ctx, rest),
69+ b"refs" if rest.is_empty() => refs::page(&ctx),
70+ _ => ctx.not_found("PAGE NOT FOUND"),
71+ }
72+}
73+
74+impl Ctx<'_> {
75+ /// `{base}{repo}` without a trailing slash.
76+ pub fn repo_url(&self) -> String {
77+ format!(
78+ "{}{}",
79+ self.config.server.base_path,
80+ encode_path(&self.repo.name)
81+ )
82+ }
83+
84+ /// `{repo}/{action}/{rev}/{path}` (no trailing slash for the root).
85+ pub fn url(&self, action: &str, rev: &str, path: &[u8]) -> String {
86+ let mut url = format!("{}/{action}/{}", self.repo_url(), encode_path(rev));
87+ if !path.is_empty() {
88+ url.push('/');
89+ url.push_str(&encode_bytes(path));
90+ }
91+ url
92+ }
93+
94+ pub fn not_found(&self, message: &str) -> Response {
95+ error::page(self.state, StatusCode::NOT_FOUND, message)
96+ }
97+
98+ pub fn internal_error(&self, e: anyhow::Error) -> Response {
99+ tracing::error!("{}: {e:#}", self.repo.name);
100+ error::page(
101+ self.state,
102+ StatusCode::INTERNAL_SERVER_ERROR,
103+ "INTERNAL ERROR",
104+ )
105+ }
106+
107+ /// The revision and path named by `segments`; an empty list means the
108+ /// default branch at the root.
109+ pub fn resolve(&self, segments: &[Vec<u8>]) -> Result<(Rev, Vec<u8>), Box<Response>> {
110+ if segments.is_empty() {
111+ let (Some(branch), Some(head)) = (&self.repo.meta.default_branch, self.repo.meta.head)
112+ else {
113+ return Err(Box::new(self.not_found("REPOSITORY IS EMPTY")));
114+ };
115+ let rev = Rev {
116+ name: branch.clone(),
117+ kind: RefKind::Branch,
118+ commit: head,
119+ };
120+ return Ok((rev, Vec::new()));
121+ }
122+ match git::refs::resolve_prefix(&self.git, segments) {
123+ Some((rev, path)) => Ok((rev, path.join(&b'/'))),
124+ None => Err(Box::new(self.not_found("UNKNOWN REVISION"))),
125+ }
126+ }
127+
128+ /// Weak ETag over everything a page depends on besides its URL: the
129+ /// content (`parts`), the repository's refs and metadata, the config and
130+ /// the build.
131+ pub fn etag(&self, parts: impl Hash) -> String {
132+ let mut hasher = std::hash::DefaultHasher::new();
133+ parts.hash(&mut hasher);
134+ self.repo.generation.hash(&mut hasher);
135+ self.state
136+ .config_generation
137+ .load(Ordering::Relaxed)
138+ .hash(&mut hasher);
139+ self.state.assets.hash().hash(&mut hasher);
140+ format!("W/\"{:016x}\"", hasher.finish())
141+ }
142+
143+ /// `Some(304)` when the client already has `etag`.
144+ pub fn not_modified(&self, etag: &str, immutable: bool) -> Option<Response> {
145+ let header = self.if_none_match?.to_str().ok()?;
146+ let bare = |t: &str| t.trim().trim_start_matches("W/").to_string();
147+ let matches = header
148+ .split(',')
149+ .any(|t| t.trim() == "*" || bare(t) == bare(etag));
150+ matches.then(|| {
151+ let mut res = StatusCode::NOT_MODIFIED.into_response();
152+ set_cache_headers(&mut res, etag, immutable);
153+ res
154+ })
155+ }
156+
157+ /// Branches and tags (with tip commits), cached per refs generation.
158+ pub fn refs(&self) -> anyhow::Result<Arc<RefList>> {
159+ let key = Key::Refs {
160+ repo: self.repo.name.clone(),
161+ generation: self.repo.generation,
162+ };
163+ self.state.cache.refs(key, || git::refs::list(&self.git))
164+ }
165+
166+ /// Nav for this repository; `picker` builds ref-picker links for a
167+ /// revision name.
168+ pub fn nav(
169+ &self,
170+ active: Active,
171+ current: Option<&Rev>,
172+ picker: impl Fn(&str) -> String,
173+ ) -> RepoNav {
174+ let tree_url = match current {
175+ Some(rev) => self.url("tree", &rev.name, b""),
176+ None => format!("{}/tree", self.repo_url()),
177+ };
178+ let picker = current.and_then(|rev| {
179+ let refs = self.refs().ok()?;
180+ let item = |name: &str| PickItem {
181+ name: name.to_string(),
182+ url: picker(name),
183+ active: rev.kind != RefKind::Commit && rev.name == name,
184+ };
185+ Some(Picker {
186+ current: match rev.kind {
187+ RefKind::Commit => short(&rev.commit.to_string()),
188+ _ => rev.name.clone(),
189+ },
190+ kind: match rev.kind {
191+ RefKind::Branch => "Branch",
192+ RefKind::Tag => "Tag",
193+ RefKind::Commit => "Commit",
194+ },
195+ branches: refs.branches.iter().map(|r| item(&r.name)).collect(),
196+ tags: refs
197+ .tags
198+ .iter()
199+ .take(PICKER_TAGS)
200+ .map(|r| item(&r.name))
201+ .collect(),
202+ more_tags: refs.tags.len() > PICKER_TAGS,
203+ })
204+ });
205+ RepoNav {
206+ name: self.repo.name.clone(),
207+ url: self.repo_url(),
208+ tree_url,
209+ refs_url: format!("{}/refs", self.repo_url()),
210+ active: active.as_str(),
211+ picker,
212+ }
213+ }
214+}
215+
216+/// Set `ETag` and `Cache-Control` for a repository page.
217+pub fn set_cache_headers(res: &mut Response, etag: &str, immutable: bool) {
218+ if let Ok(value) = HeaderValue::from_str(etag) {
219+ res.headers_mut().insert(header::ETAG, value);
220+ }
221+ let cache = if immutable {
222+ "public, max-age=86400"
223+ } else {
224+ "no-cache"
225+ };
226+ res.headers_mut()
227+ .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
228+}
229+
230+/// Redirect between the tree and blob views of the same path.
231+pub fn redirect(url: &str) -> Response {
232+ Redirect::temporary(url).into_response()
233+}
234+
235+pub fn short(oid: &str) -> String {
236+ oid.chars().take(7).collect()
237+}
238+
239+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240+pub enum Active {
241+ Summary,
242+ Tree,
243+ Refs,
244+}
245+
246+impl Active {
247+ pub fn as_str(self) -> &'static str {
248+ match self {
249+ Active::Summary => "summary",
250+ Active::Tree => "tree",
251+ Active::Refs => "refs",
252+ }
253+ }
254+}
255+
256+pub struct RepoNav {
257+ pub name: String,
258+ pub url: String,
259+ pub tree_url: String,
260+ pub refs_url: String,
261+ /// `summary`, `tree` or `refs`.
262+ pub active: &'static str,
263+ pub picker: Option<Picker>,
264+}
265+
266+pub struct Picker {
267+ /// Label of the current revision (short id for commits).
268+ pub current: String,
269+ pub kind: &'static str,
270+ pub branches: Vec<PickItem>,
271+ pub tags: Vec<PickItem>,
272+ pub more_tags: bool,
273+}
274+
275+pub struct PickItem {
276+ pub name: String,
277+ pub url: String,
278+ pub active: bool,
279+}
280+
281+/// One breadcrumb segment: the repository name, then each directory.
282+pub struct Crumb {
283+ pub name: String,
284+ pub url: String,
285+}
286+
287+/// Breadcrumbs for `path` at `rev`; the last segment is returned separately
288+/// (it is the current page and not a link).
289+pub fn crumbs(ctx: &Ctx, rev: &str, path: &[u8]) -> (Vec<Crumb>, Option<String>) {
290+ let mut crumbs = vec![Crumb {
291+ name: ctx.repo.name.clone(),
292+ url: ctx.url("tree", rev, b""),
293+ }];
294+ if path.is_empty() {
295+ return (crumbs, None);
296+ }
297+ let parts: Vec<&[u8]> = path.split(|b| *b == b'/').collect();
298+ let (last, dirs) = parts.split_last().expect("non-empty path");
299+ for (i, part) in dirs.iter().enumerate() {
300+ crumbs.push(Crumb {
301+ name: String::from_utf8_lossy(part).into_owned(),
302+ url: ctx.url("tree", rev, &parts[..=i].join(&b'/')),
303+ });
304+ }
305+ (crumbs, Some(String::from_utf8_lossy(last).into_owned()))
306+}
A src/handlers/summary.rs View
@@ -0,0 +1,178 @@
1+//! `/{repo}` — root tree, README and a rail with stats, clone URLs and the
2+//! latest commit.
3+
4+use askama::Template;
5+use axum::http::StatusCode;
6+use axum::response::Response;
7+
8+use super::repo::{Active, Ctx, RepoNav, set_cache_headers, short};
9+use super::tree::{Readme, TreeRow, listing, readme, rows};
10+use super::{Layout, render};
11+use crate::cache::Key;
12+use crate::git::refs::{RefKind, Rev};
13+use crate::git::{self, CommitInfo};
14+use crate::render::time;
15+
16+pub struct Stat {
17+ pub label: &'static str,
18+ pub value: String,
19+ pub title: Option<String>,
20+}
21+
22+pub struct CloneUrl {
23+ pub label: String,
24+ pub url: String,
25+}
26+
27+pub struct Latest {
28+ pub subject: String,
29+ pub author: String,
30+ pub age: String,
31+ pub iso: String,
32+ pub id: String,
33+ pub url: String,
34+}
35+
36+#[derive(Template)]
37+#[template(path = "summary.html")]
38+pub struct SummaryPage {
39+ pub layout: Layout,
40+ pub nav: RepoNav,
41+ pub name: String,
42+ pub description: Option<String>,
43+ pub stats: Vec<Stat>,
44+ pub clone_urls: Vec<CloneUrl>,
45+ pub latest: Option<Latest>,
46+ /// `None` for an empty repository.
47+ pub rows: Option<Vec<TreeRow>>,
48+ pub tree_url: String,
49+ pub readme: Option<Readme>,
50+}
51+
52+pub fn page(ctx: &Ctx) -> Response {
53+ let meta = &ctx.repo.meta;
54+ let rev = meta
55+ .default_branch
56+ .clone()
57+ .zip(meta.head)
58+ .map(|(name, commit)| Rev {
59+ name,
60+ kind: RefKind::Branch,
61+ commit,
62+ });
63+ let etag = ctx.etag(("summary", rev.as_ref().map(|r| r.commit)));
64+ if let Some(res) = ctx.not_modified(&etag, false) {
65+ return res;
66+ }
67+ let mut page = SummaryPage {
68+ layout: Layout::new(ctx.state, ctx.config, Some(&ctx.repo.name)),
69+ nav: ctx.nav(Active::Summary, rev.as_ref(), |name| {
70+ ctx.url("tree", name, b"")
71+ }),
72+ name: ctx.repo.name.clone(),
73+ description: meta.description.clone(),
74+ stats: Vec::new(),
75+ clone_urls: clone_urls(ctx),
76+ latest: None,
77+ rows: None,
78+ tree_url: String::new(),
79+ readme: None,
80+ };
81+
82+ if let Some(rev) = &rev {
83+ let result = (|| -> anyhow::Result<()> {
84+ let commits = ctx.state.cache.count(Key::CommitCount(rev.commit), || {
85+ git::commit_count(&ctx.git, rev.commit)
86+ })?;
87+ let info = git::commit_info(&ctx.git, rev.commit)?;
88+ page.stats = stats(ctx, Some(commits));
89+ page.latest = Some(latest(ctx, &info));
90+ let root = git::tree::lookup(&ctx.git, rev.commit, b"")?;
91+ if let Some(git::tree::PathTarget::Tree(tree_id)) = root {
92+ page.rows = Some(rows(ctx, rev, b"", &listing(ctx, tree_id)?));
93+ }
94+ page.tree_url = ctx.url("tree", &rev.name, b"");
95+ page.readme = meta.readme.as_ref().and_then(|r| {
96+ let (dir, name) = r.path.rsplit_once('/').unwrap_or(("", &r.path));
97+ readme(ctx, rev, dir.as_bytes(), name, r.oid)
98+ });
99+ Ok(())
100+ })();
101+ if let Err(e) = result {
102+ return ctx.internal_error(e);
103+ }
104+ } else {
105+ page.stats = stats(ctx, None);
106+ }
107+
108+ let mut res = render(StatusCode::OK, &page);
109+ set_cache_headers(&mut res, &etag, false);
110+ res
111+}
112+
113+fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> {
114+ let meta = &ctx.repo.meta;
115+ let mut stats = vec![
116+ Stat {
117+ label: "Commits",
118+ value: commits.unwrap_or(0).to_string(),
119+ title: None,
120+ },
121+ Stat {
122+ label: "Branches",
123+ value: meta.branches.to_string(),
124+ title: None,
125+ },
126+ Stat {
127+ label: "Tags",
128+ value: meta.tags.to_string(),
129+ title: None,
130+ },
131+ ];
132+ if let Some(t) = meta.last_activity {
133+ stats.push(Stat {
134+ label: "Updated",
135+ value: time::relative(t, time::now()),
136+ title: Some(time::iso(t)),
137+ });
138+ }
139+ if let Some(owner) = &meta.owner {
140+ stats.push(Stat {
141+ label: "Owner",
142+ value: owner.clone(),
143+ title: None,
144+ });
145+ }
146+ stats
147+}
148+
149+fn latest(ctx: &Ctx, info: &CommitInfo) -> Latest {
150+ let id = info.id.to_string();
151+ Latest {
152+ subject: info.subject.clone(),
153+ author: info.author_name.clone(),
154+ age: time::relative(info.committer_time, time::now()),
155+ iso: time::iso(info.committer_time),
156+ url: ctx.url("tree", &id, b""),
157+ id: short(&id),
158+ }
159+}
160+
161+/// `site.clone_urls` with `{name}` filled in, labeled by protocol.
162+fn clone_urls(ctx: &Ctx) -> Vec<CloneUrl> {
163+ ctx.config
164+ .site
165+ .clone_urls
166+ .iter()
167+ .map(|template| {
168+ let url = template.replace("{name}", &ctx.repo.name);
169+ let label = match url.split_once("://") {
170+ Some(("ssh", _)) => "SSH".to_string(),
171+ Some((scheme, _)) => scheme.to_ascii_uppercase(),
172+ // scp-like `user@host:path`
173+ None => "SSH".to_string(),
174+ };
175+ CloneUrl { label, url }
176+ })
177+ .collect()
178+}
A src/handlers/tree.rs View
@@ -0,0 +1,201 @@
1+//! `/{repo}/tree/{rev}/{path…}` — directory listing, plus the directory's
2+//! README. Also provides the listing and README rendering used by the
3+//! summary page.
4+
5+use std::sync::Arc;
6+
7+use askama::Template;
8+use axum::http::StatusCode;
9+use axum::response::Response;
10+use gix::bstr::ByteSlice;
11+
12+use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers, short};
13+use super::{Layout, render};
14+use crate::cache::Key;
15+use crate::git::ObjectId;
16+use crate::git::refs::Rev;
17+use crate::git::tree::{self, EntryKind, PathTarget, TreeEntry};
18+use crate::render::markdown::{self, Links};
19+use crate::render::{encode_path, human_size};
20+
21+/// READMEs larger than this are not rendered inline.
22+const MAX_README: u64 = 512 * 1024;
23+
24+pub struct TreeRow {
25+ pub name: String,
26+ /// `None` for submodules (nothing to browse here).
27+ pub url: Option<String>,
28+ pub icon: &'static str,
29+ /// Size for files, short commit id for submodules.
30+ pub meta: String,
31+}
32+
33+#[derive(Template)]
34+#[template(path = "tree.html")]
35+pub struct TreePage {
36+ pub layout: Layout,
37+ pub nav: RepoNav,
38+ pub crumbs: Vec<Crumb>,
39+ pub here: Option<String>,
40+ /// Includes a leading `..` row below the root.
41+ pub rows: Vec<TreeRow>,
42+ pub entries: usize,
43+ pub tree_id: String,
44+ pub readme: Option<Readme>,
45+}
46+
47+pub struct Readme {
48+ pub name: String,
49+ pub url: String,
50+ pub html: Arc<str>,
51+}
52+
53+pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response {
54+ let (rev, path) = match ctx.resolve(segments) {
55+ Ok(found) => found,
56+ Err(res) => return *res,
57+ };
58+ let tree_id = match tree::lookup(&ctx.git, rev.commit, &path) {
59+ Ok(Some(PathTarget::Tree(id))) => id,
60+ Ok(Some(PathTarget::Blob(..))) => return redirect(&ctx.url("blob", &rev.name, &path)),
61+ Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"),
62+ Ok(None) => {
63+ return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name));
64+ }
65+ Err(e) => return ctx.internal_error(e),
66+ };
67+
68+ let etag = ctx.etag(("tree", tree_id, &rev.name));
69+ let immutable = rev.is_immutable();
70+ if let Some(res) = ctx.not_modified(&etag, immutable) {
71+ return res;
72+ }
73+ let entries = match listing(ctx, tree_id) {
74+ Ok(entries) => entries,
75+ Err(e) => return ctx.internal_error(e),
76+ };
77+
78+ let (crumbs, here) = crumbs(ctx, &rev.name, &path);
79+ let mut rows = rows(ctx, &rev, &path, &entries);
80+ if !path.is_empty() {
81+ let up = path.rsplit_once_str("/").map_or(&b""[..], |(dir, _)| dir);
82+ rows.insert(
83+ 0,
84+ TreeRow {
85+ name: "..".into(),
86+ url: Some(ctx.url("tree", &rev.name, up)),
87+ icon: "folder",
88+ meta: String::new(),
89+ },
90+ );
91+ }
92+ let title = match &here {
93+ Some(_) => format!("{} · {}", path.to_str_lossy(), ctx.repo.name),
94+ None => ctx.repo.name.clone(),
95+ };
96+ let page = TreePage {
97+ layout: Layout::new(ctx.state, ctx.config, Some(&title)),
98+ nav: ctx.nav(Active::Tree, Some(&rev), |name| {
99+ ctx.url("tree", name, &path)
100+ }),
101+ crumbs,
102+ here,
103+ rows,
104+ entries: entries.len(),
105+ tree_id: short(&tree_id.to_string()),
106+ readme: dir_readme(ctx, &rev, &path, &entries),
107+ };
108+ let mut res = render(StatusCode::OK, &page);
109+ set_cache_headers(&mut res, &etag, immutable);
110+ res
111+}
112+
113+/// Cached listing of a tree.
114+pub fn listing(ctx: &Ctx, tree_id: ObjectId) -> anyhow::Result<Arc<Vec<TreeEntry>>> {
115+ ctx.state
116+ .cache
117+ .tree(tree_id, || tree::list(&ctx.git, tree_id))
118+}
119+
120+pub fn rows(ctx: &Ctx, rev: &Rev, dir: &[u8], entries: &[TreeEntry]) -> Vec<TreeRow> {
121+ entries
122+ .iter()
123+ .map(|entry| {
124+ let mut path = dir.to_vec();
125+ if !path.is_empty() {
126+ path.push(b'/');
127+ }
128+ path.extend_from_slice(&entry.name);
129+ let (action, icon) = match entry.kind {
130+ EntryKind::Tree => ("tree", "folder"),
131+ EntryKind::Symlink => ("blob", "link"),
132+ EntryKind::Submodule => ("", "package"),
133+ EntryKind::Blob | EntryKind::Executable => ("blob", "file"),
134+ };
135+ TreeRow {
136+ name: entry.name.to_str_lossy().into_owned(),
137+ url: (!action.is_empty()).then(|| ctx.url(action, &rev.name, &path)),
138+ icon,
139+ meta: match entry.kind {
140+ EntryKind::Submodule => format!("@ {}", short(&entry.oid.to_string())),
141+ _ => entry.size.map(human_size).unwrap_or_default(),
142+ },
143+ }
144+ })
145+ .collect()
146+}
147+
148+/// Names tried for a directory README, in order of preference.
149+const README_NAMES: &[&str] = &["readme.md", "readme.markdown", "readme", "readme.txt"];
150+
151+fn dir_readme(ctx: &Ctx, rev: &Rev, dir: &[u8], entries: &[TreeEntry]) -> Option<Readme> {
152+ let entry = README_NAMES.iter().find_map(|want| {
153+ entries.iter().find(|e| {
154+ e.kind.is_blob()
155+ && e.kind != EntryKind::Symlink
156+ && e.name.eq_ignore_ascii_case(want.as_bytes())
157+ })
158+ })?;
159+ let name = entry.name.to_str_lossy().into_owned();
160+ readme(ctx, rev, dir, &name, entry.oid)
161+}
162+
163+/// Render a README blob: Markdown as HTML, anything else as plain text.
164+pub fn readme(ctx: &Ctx, rev: &Rev, dir: &[u8], name: &str, oid: ObjectId) -> Option<Readme> {
165+ if tree::size(&ctx.git, oid).ok()? > MAX_README {
166+ return None;
167+ }
168+ let links = Links {
169+ repo_url: ctx.repo_url(),
170+ rev: encode_path(&rev.name),
171+ dir: dir.to_vec(),
172+ };
173+ let is_md = markdown::is_markdown(name);
174+ let key = Key::Markdown {
175+ blob: oid,
176+ context: if is_md { links.key() } else { String::new() },
177+ };
178+ let html = ctx
179+ .state
180+ .cache
181+ .html(key, || -> anyhow::Result<String> {
182+ let data = tree::blob(&ctx.git, oid)?;
183+ let text = String::from_utf8_lossy(&data);
184+ Ok(if is_md {
185+ markdown::render(&text, &links)
186+ } else {
187+ markdown::render_plain(&text)
188+ })
189+ })
190+ .ok()?;
191+ let mut path = dir.to_vec();
192+ if !path.is_empty() {
193+ path.push(b'/');
194+ }
195+ path.extend_from_slice(name.as_bytes());
196+ Some(Readme {
197+ name: name.to_string(),
198+ url: ctx.url("blob", &rev.name, &path),
199+ html,
200+ })
201+}
A src/index/meta.rs View
@@ -0,0 +1,167 @@
1+//! Per-repository metadata, resolved by precedence:
2+//! `[[repo]]` in ferrenium.toml > repo git config > `description` file >
3+//! defaults.
4+
5+use std::path::Path;
6+
7+use gix::bstr::BStr;
8+use gix::glob::wildmatch;
9+
10+use super::scan::Candidate;
11+use crate::config::{Config, RepoOverride};
12+use crate::git::{self, ObjectId, ThreadSafeRepository};
13+
14+#[derive(Debug, Clone, PartialEq, Eq)]
15+pub struct Readme {
16+ pub path: String,
17+ pub oid: ObjectId,
18+}
19+
20+#[derive(Debug, Default, Clone, PartialEq, Eq)]
21+pub struct RepoMeta {
22+ pub description: Option<String>,
23+ pub owner: Option<String>,
24+ pub section: Option<String>,
25+ /// Branch shown by default (may be unborn in an empty repo).
26+ pub default_branch: Option<String>,
27+ /// Commit the default branch points to; `None` for an empty repo.
28+ pub head: Option<ObjectId>,
29+ pub branches: usize,
30+ pub tags: usize,
31+ /// Unix seconds of the newest commit across branch tips.
32+ pub last_activity: Option<i64>,
33+ pub readme: Option<Readme>,
34+ pub refs_fingerprint: u64,
35+}
36+
37+pub struct Inspected {
38+ pub gix: ThreadSafeRepository,
39+ pub meta: RepoMeta,
40+ pub hidden: bool,
41+ pub warnings: Vec<String>,
42+}
43+
44+/// git's placeholder `description`, which means "no description".
45+const PLACEHOLDER_DESCRIPTION: &str = "Unnamed repository;";
46+
47+pub fn inspect(config: &Config, candidate: &Candidate) -> anyhow::Result<Inspected> {
48+ let gix = git::open(&candidate.git_dir)?;
49+ let repo = gix.to_thread_local();
50+ let name = candidate.name.as_str();
51+ let toml = config.repo_overrides.iter().find(|r| r.name == name);
52+ let toml = toml.cloned().unwrap_or_default();
53+ let cfg = git::config_meta(&repo);
54+ let mut warnings = Vec::new();
55+
56+ let hidden =
57+ is_hidden_by_glob(&config.repos.hide, name) || toml.hidden.or(cfg.hidden).unwrap_or(false);
58+ if hidden {
59+ return Ok(Inspected {
60+ gix,
61+ meta: RepoMeta::default(),
62+ hidden,
63+ warnings,
64+ });
65+ }
66+
67+ let section = toml.section.clone().or(cfg.section.clone()).or_else(|| {
68+ config
69+ .repos
70+ .section_from_path
71+ .then(|| name.split_once('/').map(|(first, _)| first.to_string()))
72+ .flatten()
73+ });
74+
75+ let head_branch = git::head_branch(&repo);
76+ let mut default_branch = toml
77+ .default_branch
78+ .clone()
79+ .or(cfg.default_branch.clone())
80+ .or(head_branch.clone());
81+ let mut head = default_branch
82+ .as_deref()
83+ .and_then(|b| git::branch_commit(&repo, b));
84+ if head.is_none()
85+ && let Some(fallback) = head_branch.as_deref()
86+ && default_branch.as_deref() != Some(fallback)
87+ && let Some(commit) = git::branch_commit(&repo, fallback)
88+ {
89+ warnings.push(format!(
90+ "{name}: default branch {:?} does not exist; using {fallback:?}",
91+ default_branch.as_deref().unwrap_or_default()
92+ ));
93+ default_branch = Some(fallback.to_string());
94+ head = Some(commit);
95+ }
96+
97+ let readme_path = toml.readme.clone().or(cfg.readme.clone());
98+ let readme = head.and_then(|commit| {
99+ let found = git::find_readme(&repo, commit, readme_path.as_deref());
100+ if found.is_none()
101+ && let Some(path) = &readme_path
102+ {
103+ warnings.push(format!("{name}: readme {path:?} not found"));
104+ }
105+ found.map(|(path, oid)| Readme { path, oid })
106+ });
107+
108+ let refs = git::ref_summary(&repo)?;
109+ let meta = RepoMeta {
110+ description: description(&toml, cfg.description.clone(), &candidate.git_dir),
111+ owner: toml.owner.clone().or(cfg.owner.clone()),
112+ section,
113+ default_branch,
114+ head,
115+ branches: refs.branches,
116+ tags: refs.tags,
117+ last_activity: refs.last_activity,
118+ readme,
119+ refs_fingerprint: refs.fingerprint,
120+ };
121+ Ok(Inspected {
122+ gix,
123+ meta,
124+ hidden,
125+ warnings,
126+ })
127+}
128+
129+fn description(toml: &RepoOverride, from_config: Option<String>, git_dir: &Path) -> Option<String> {
130+ toml.description
131+ .clone()
132+ .or(from_config)
133+ .or_else(|| {
134+ let text = std::fs::read_to_string(git_dir.join("description")).ok()?;
135+ let text = text.trim();
136+ (!text.is_empty() && !text.starts_with(PLACEHOLDER_DESCRIPTION))
137+ .then(|| text.to_string())
138+ })
139+ .filter(|d| !d.trim().is_empty())
140+}
141+
142+/// `repos.hide` globs: `*` stays within one path component, `**` crosses.
143+pub fn is_hidden_by_glob(patterns: &[String], name: &str) -> bool {
144+ patterns.iter().any(|pattern| {
145+ wildmatch(
146+ BStr::new(pattern.as_bytes()),
147+ BStr::new(name.as_bytes()),
148+ wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
149+ )
150+ })
151+}
152+
153+#[cfg(test)]
154+mod tests {
155+ use super::*;
156+
157+ #[test]
158+ fn hide_globs() {
159+ let pats = vec!["private/*".to_string(), "*-wip".to_string()];
160+ assert!(is_hidden_by_glob(&pats, "private/x"));
161+ assert!(!is_hidden_by_glob(&pats, "private/x/y"));
162+ assert!(is_hidden_by_glob(&pats, "thing-wip"));
163+ assert!(!is_hidden_by_glob(&pats, "dir/thing-wip"));
164+ assert!(!is_hidden_by_glob(&pats, "public"));
165+ assert!(is_hidden_by_glob(&["**/secret".into()], "a/b/secret"));
166+ }
167+}
A src/index/mod.rs View
@@ -0,0 +1,253 @@
1+//! Repository discovery and the in-memory repository index.
2+//!
3+//! Readers get a consistent snapshot via [`Indexer::load`] without locking.
4+//! Writers (full scans and single-repo refreshes) are serialized and swap in
5+//! a new snapshot copy-on-write.
6+
7+pub mod meta;
8+pub mod scan;
9+pub mod watch;
10+
11+use std::collections::{BTreeMap, BTreeSet};
12+use std::path::{Path, PathBuf};
13+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
14+use std::sync::{Arc, Mutex};
15+use std::time::{Duration, Instant};
16+
17+use arc_swap::ArcSwap;
18+
19+use crate::config::Config;
20+use crate::git::ThreadSafeRepository;
21+use meta::{Inspected, RepoMeta};
22+use scan::Candidate;
23+
24+pub struct Repo {
25+ pub name: String,
26+ pub git_dir: PathBuf,
27+ pub meta: RepoMeta,
28+ pub gix: ThreadSafeRepository,
29+ /// Changes whenever this repo's refs or metadata change; part of cache
30+ /// keys for ref-dependent data.
31+ pub generation: u64,
32+}
33+
34+#[derive(Default, Clone)]
35+pub struct RepoIndex {
36+ /// Visible repositories by name.
37+ pub repos: BTreeMap<String, Arc<Repo>>,
38+ /// Every discovered git dir (hidden ones included) → name.
39+ pub git_dirs: BTreeMap<PathBuf, String>,
40+ pub hidden: BTreeSet<String>,
41+ /// Non-repository directories walked during discovery.
42+ pub dirs: Vec<PathBuf>,
43+ pub warnings: Vec<String>,
44+}
45+
46+#[derive(Debug, Default, PartialEq, Eq)]
47+pub struct ScanStats {
48+ pub total: usize,
49+ pub hidden: usize,
50+ pub added: usize,
51+ pub removed: usize,
52+ pub changed: usize,
53+ pub elapsed: Duration,
54+}
55+
56+impl ScanStats {
57+ pub fn any_change(&self) -> bool {
58+ self.added + self.removed + self.changed > 0
59+ }
60+}
61+
62+#[derive(Debug, PartialEq, Eq)]
63+pub enum Refresh {
64+ Unchanged,
65+ Updated(String),
66+ Hidden(String),
67+ /// The path is not (or no longer) a known repository: do a full scan.
68+ NeedsRescan,
69+ Failed(String),
70+}
71+
72+pub struct Indexer {
73+ current: ArcSwap<RepoIndex>,
74+ generation: AtomicU64,
75+ write: Mutex<()>,
76+}
77+
78+impl Default for Indexer {
79+ fn default() -> Self {
80+ Self::new()
81+ }
82+}
83+
84+impl Indexer {
85+ pub fn new() -> Self {
86+ Indexer {
87+ current: ArcSwap::from_pointee(RepoIndex::default()),
88+ generation: AtomicU64::new(0),
89+ write: Mutex::new(()),
90+ }
91+ }
92+
93+ pub fn load(&self) -> Arc<RepoIndex> {
94+ self.current.load_full()
95+ }
96+
97+ /// Rediscover everything under the scan path and rebuild the index.
98+ pub fn full_scan(&self, config: &Config) -> ScanStats {
99+ let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner());
100+ let start = Instant::now();
101+ let old = self.current.load_full();
102+ let discovery = scan::discover(
103+ &config.repos.scan_path,
104+ config.repos.max_depth,
105+ config.repos.follow_symlinks,
106+ );
107+
108+ let mut new = RepoIndex {
109+ dirs: discovery.dirs,
110+ warnings: discovery.warnings,
111+ ..RepoIndex::default()
112+ };
113+ let inspected = inspect_all(config, &discovery.candidates);
114+ for (candidate, result) in discovery.candidates.into_iter().zip(inspected) {
115+ new.git_dirs
116+ .insert(candidate.git_dir.clone(), candidate.name.clone());
117+ match result {
118+ Err(e) => new.warnings.push(format!("{}: {e:#}", candidate.name)),
119+ Ok(inspected) => {
120+ new.warnings.extend(inspected.warnings.iter().cloned());
121+ if inspected.hidden {
122+ new.hidden.insert(candidate.name);
123+ } else {
124+ let repo = self.make_repo(&old, candidate, inspected);
125+ new.repos.insert(repo.name.clone(), repo);
126+ }
127+ }
128+ }
129+ }
130+ let known: BTreeSet<&String> = new.git_dirs.values().collect();
131+ for o in &config.repo_overrides {
132+ if !known.contains(&o.name) {
133+ new.warnings.push(format!(
134+ "[[repo]] {:?} does not match any repository",
135+ o.name
136+ ));
137+ }
138+ }
139+
140+ let mut stats = ScanStats {
141+ total: new.repos.len(),
142+ hidden: new.hidden.len(),
143+ ..ScanStats::default()
144+ };
145+ for (name, repo) in &new.repos {
146+ match old.repos.get(name) {
147+ None => stats.added += 1,
148+ Some(prev) if !Arc::ptr_eq(prev, repo) => stats.changed += 1,
149+ Some(_) => {}
150+ }
151+ }
152+ stats.removed = old
153+ .repos
154+ .keys()
155+ .filter(|name| !new.repos.contains_key(*name))
156+ .count();
157+ self.current.store(Arc::new(new));
158+ stats.elapsed = start.elapsed();
159+ stats
160+ }
161+
162+ /// Re-inspect the single repository at `git_dir` (after a ref change).
163+ pub fn refresh(&self, config: &Config, git_dir: &Path) -> Refresh {
164+ let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner());
165+ let old = self.current.load_full();
166+ let Some(name) = old.git_dirs.get(git_dir).cloned() else {
167+ return Refresh::NeedsRescan;
168+ };
169+ if scan::repo_git_dir(git_dir).as_deref() != Some(git_dir) {
170+ return Refresh::NeedsRescan;
171+ }
172+ let candidate = Candidate {
173+ name: name.clone(),
174+ git_dir: git_dir.to_path_buf(),
175+ };
176+ let inspected = match meta::inspect(config, &candidate) {
177+ Ok(inspected) => inspected,
178+ // Keep serving the previous state; the next event or poll retries.
179+ Err(e) => return Refresh::Failed(format!("{name}: {e:#}")),
180+ };
181+
182+ let mut new = (*old).clone();
183+ if inspected.hidden {
184+ if new.repos.remove(&name).is_none() {
185+ return Refresh::Unchanged;
186+ }
187+ new.hidden.insert(name.clone());
188+ self.current.store(Arc::new(new));
189+ return Refresh::Hidden(name);
190+ }
191+ let repo = self.make_repo(&old, candidate, inspected);
192+ if old
193+ .repos
194+ .get(&name)
195+ .is_some_and(|prev| Arc::ptr_eq(prev, &repo))
196+ {
197+ return Refresh::Unchanged;
198+ }
199+ new.hidden.remove(&name);
200+ new.repos.insert(name.clone(), repo);
201+ self.current.store(Arc::new(new));
202+ Refresh::Updated(name)
203+ }
204+
205+ /// Reuse the previous entry when nothing changed, so its generation (and
206+ /// everything cached under it) stays valid.
207+ fn make_repo(&self, old: &RepoIndex, candidate: Candidate, inspected: Inspected) -> Arc<Repo> {
208+ if let Some(prev) = old.repos.get(&candidate.name)
209+ && prev.git_dir == candidate.git_dir
210+ && prev.meta == inspected.meta
211+ {
212+ return prev.clone();
213+ }
214+ Arc::new(Repo {
215+ name: candidate.name,
216+ git_dir: candidate.git_dir,
217+ meta: inspected.meta,
218+ gix: inspected.gix,
219+ generation: self.generation.fetch_add(1, Ordering::Relaxed) + 1,
220+ })
221+ }
222+}
223+
224+/// Inspect candidates on a few threads; results keep the input order.
225+fn inspect_all(config: &Config, candidates: &[Candidate]) -> Vec<anyhow::Result<Inspected>> {
226+ let workers = std::thread::available_parallelism()
227+ .map_or(4, |n| n.get())
228+ .min(candidates.len())
229+ .max(1);
230+ let next = AtomicUsize::new(0);
231+ let mut results: Vec<(usize, anyhow::Result<Inspected>)> = std::thread::scope(|s| {
232+ let handles: Vec<_> = (0..workers)
233+ .map(|_| {
234+ s.spawn(|| {
235+ let mut out = Vec::new();
236+ loop {
237+ let i = next.fetch_add(1, Ordering::Relaxed);
238+ let Some(candidate) = candidates.get(i) else {
239+ break out;
240+ };
241+ out.push((i, meta::inspect(config, candidate)));
242+ }
243+ })
244+ })
245+ .collect();
246+ handles
247+ .into_iter()
248+ .flat_map(|h| h.join().expect("inspect worker panicked"))
249+ .collect()
250+ });
251+ results.sort_by_key(|(i, _)| *i);
252+ results.into_iter().map(|(_, r)| r).collect()
253+}
A src/index/scan.rs View
@@ -0,0 +1,186 @@
1+//! Filesystem discovery of repositories under `repos.scan_path`.
2+
3+use std::collections::BTreeMap;
4+use std::path::{Path, PathBuf};
5+
6+/// A repository found on disk.
7+#[derive(Debug, Clone, PartialEq, Eq)]
8+pub struct Candidate {
9+ /// Path relative to the scan path, `.git` suffix stripped: `forks/foo`.
10+ pub name: String,
11+ /// The git dir: the bare repo itself, or `<worktree>/.git`.
12+ pub git_dir: PathBuf,
13+}
14+
15+#[derive(Debug, Default)]
16+pub struct Discovery {
17+ pub candidates: Vec<Candidate>,
18+ /// Directories walked that are not repositories; watched for repos
19+ /// appearing or disappearing.
20+ pub dirs: Vec<PathBuf>,
21+ pub warnings: Vec<String>,
22+}
23+
24+pub fn discover(scan_path: &Path, max_depth: u32, follow_symlinks: bool) -> Discovery {
25+ let mut out = Discovery::default();
26+ let mut found: BTreeMap<String, PathBuf> = BTreeMap::new();
27+
28+ if repo_git_dir(scan_path).is_some() {
29+ out.warnings.push(format!(
30+ "repos.scan_path {} is itself a repository; point it at the directory containing repositories",
31+ scan_path.display()
32+ ));
33+ return out;
34+ }
35+ walk(
36+ scan_path,
37+ scan_path,
38+ 0,
39+ max_depth,
40+ follow_symlinks,
41+ &mut found,
42+ &mut out,
43+ );
44+ out.candidates = found
45+ .into_iter()
46+ .map(|(name, git_dir)| Candidate { name, git_dir })
47+ .collect();
48+ out
49+}
50+
51+fn walk(
52+ root: &Path,
53+ dir: &Path,
54+ depth: u32,
55+ max_depth: u32,
56+ follow_symlinks: bool,
57+ found: &mut BTreeMap<String, PathBuf>,
58+ out: &mut Discovery,
59+) {
60+ let entries = match std::fs::read_dir(dir) {
61+ Ok(entries) => entries,
62+ Err(e) => {
63+ out.warnings
64+ .push(format!("cannot read {}: {e}", dir.display()));
65+ return;
66+ }
67+ };
68+ out.dirs.push(dir.to_path_buf());
69+
70+ let mut subdirs: Vec<PathBuf> = entries
71+ .flatten()
72+ .filter(|entry| !entry.file_name().as_encoded_bytes().starts_with(b"."))
73+ .filter(|entry| match entry.file_type() {
74+ Ok(t) if t.is_dir() => true,
75+ Ok(t) if t.is_symlink() && follow_symlinks => entry.path().is_dir(),
76+ _ => false,
77+ })
78+ .map(|entry| entry.path())
79+ .collect();
80+ subdirs.sort();
81+
82+ for path in subdirs {
83+ if let Some(git_dir) = repo_git_dir(&path) {
84+ let Some(name) = repo_name(root, &path) else {
85+ out.warnings.push(format!(
86+ "skipping {}: path is not valid UTF-8",
87+ path.display()
88+ ));
89+ continue;
90+ };
91+ if let Some(existing) = found.get(&name) {
92+ out.warnings.push(format!(
93+ "name collision: {} and {} are both named {name:?}; keeping the first",
94+ existing.display(),
95+ git_dir.display()
96+ ));
97+ continue;
98+ }
99+ found.insert(name, git_dir);
100+ } else if depth + 1 < max_depth {
101+ walk(
102+ root,
103+ &path,
104+ depth + 1,
105+ max_depth,
106+ follow_symlinks,
107+ found,
108+ out,
109+ );
110+ }
111+ }
112+}
113+
114+/// The git dir if `path` is a bare repository or a worktree with a `.git` dir.
115+pub fn repo_git_dir(path: &Path) -> Option<PathBuf> {
116+ if is_bare(path) {
117+ return Some(path.to_path_buf());
118+ }
119+ let dot_git = path.join(".git");
120+ is_bare(&dot_git).then_some(dot_git)
121+}
122+
123+fn is_bare(path: &Path) -> bool {
124+ path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir()
125+}
126+
127+fn repo_name(root: &Path, path: &Path) -> Option<String> {
128+ let rel = path.strip_prefix(root).ok()?.to_str()?;
129+ let name = rel.strip_suffix(".git").unwrap_or(rel);
130+ (!name.is_empty() && !name.ends_with('/')).then(|| name.to_string())
131+}
132+
133+#[cfg(test)]
134+mod tests {
135+ use super::*;
136+
137+ fn fake_bare(path: &Path) {
138+ std::fs::create_dir_all(path.join("objects")).unwrap();
139+ std::fs::create_dir_all(path.join("refs")).unwrap();
140+ std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
141+ }
142+
143+ #[test]
144+ fn discovers_bare_and_worktree_repos() {
145+ let dir = tempfile::tempdir().unwrap();
146+ let root = dir.path();
147+ fake_bare(&root.join("a.git"));
148+ fake_bare(&root.join("forks/b.git"));
149+ fake_bare(&root.join("work/c/.git"));
150+ fake_bare(&root.join("deep/1/2/3/d.git")); // beyond max_depth 3
151+ fake_bare(&root.join(".hidden/e.git"));
152+ fake_bare(&root.join("a.git/nested.git")); // inside a repo: not walked
153+ std::fs::create_dir_all(root.join("empty")).unwrap();
154+
155+ let d = discover(root, 3, false);
156+ let names: Vec<_> = d.candidates.iter().map(|c| c.name.as_str()).collect();
157+ assert_eq!(names, ["a", "forks/b", "work/c"]);
158+ assert_eq!(d.candidates[2].git_dir, root.join("work/c/.git"));
159+ assert!(d.dirs.contains(&root.join("forks")));
160+ assert!(d.dirs.contains(&root.join("empty")));
161+ assert!(!d.dirs.contains(&root.join("a.git")));
162+ assert!(d.warnings.is_empty(), "{:?}", d.warnings);
163+ }
164+
165+ #[test]
166+ fn collisions_warn_and_keep_first() {
167+ let dir = tempfile::tempdir().unwrap();
168+ fake_bare(&dir.path().join("x"));
169+ fake_bare(&dir.path().join("x.git"));
170+ let d = discover(dir.path(), 3, false);
171+ assert_eq!(d.candidates.len(), 1);
172+ assert_eq!(d.candidates[0].git_dir, dir.path().join("x"));
173+ assert!(d.warnings[0].contains("name collision"));
174+ }
175+
176+ #[test]
177+ fn symlinks_followed_only_when_enabled() {
178+ let dir = tempfile::tempdir().unwrap();
179+ let elsewhere = tempfile::tempdir().unwrap();
180+ fake_bare(&elsewhere.path().join("r.git"));
181+ std::os::unix::fs::symlink(elsewhere.path().join("r.git"), dir.path().join("r.git"))
182+ .unwrap();
183+ assert!(discover(dir.path(), 3, false).candidates.is_empty());
184+ assert_eq!(discover(dir.path(), 3, true).candidates.len(), 1);
185+ }
186+}
A src/index/watch.rs View
@@ -0,0 +1,266 @@
1+//! Keeps the index fresh: inotify watches (debounced per repository), a
2+//! periodic full rescan as a fallback, and rescans requested on SIGHUP.
3+//!
4+//! Watched paths:
5+//! - each git dir, non-recursively (`HEAD`, `packed-refs`, `config`,
6+//! `description`) and its `refs/` recursively; objects are never watched;
7+//! - every non-repository directory walked during discovery, so repositories
8+//! appearing or disappearing trigger a rescan.
9+
10+use std::collections::{BTreeSet, HashMap};
11+use std::path::{Path, PathBuf};
12+use std::sync::Arc;
13+use std::time::Duration;
14+
15+use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
16+use tokio::sync::mpsc;
17+use tokio::time::{Instant, Interval, MissedTickBehavior};
18+
19+use super::{Refresh, RepoIndex, ScanStats};
20+use crate::state::AppState;
21+
22+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23+enum Task {
24+ Rescan,
25+ Repo(PathBuf),
26+}
27+
28+/// Run forever. Call after the initial scan; `seen` holds the warnings that
29+/// scan already logged.
30+pub async fn run(state: Arc<AppState>, mut seen: BTreeSet<String>) {
31+ let (tx, mut rx) = mpsc::unbounded_channel::<notify::Result<Event>>();
32+ let mut fs = FsWatch::new(&state, tx);
33+ fs.sync(&state.index.load());
34+
35+ let mut pending: HashMap<Task, Instant> = HashMap::new();
36+ let mut poll_secs = state.config.load().watch.poll_interval_secs;
37+ let mut poll = poll_timer(poll_secs);
38+
39+ loop {
40+ let config = state.config.load_full();
41+ if config.watch.poll_interval_secs != poll_secs {
42+ poll_secs = config.watch.poll_interval_secs;
43+ poll = poll_timer(poll_secs);
44+ }
45+ let debounce = Duration::from_millis(config.watch.debounce_ms);
46+ let next_due = pending.values().min().copied();
47+
48+ tokio::select! {
49+ Some(event) = rx.recv() => {
50+ let index = state.index.load();
51+ for task in classify(event, &index) {
52+ pending.entry(task).or_insert_with(|| Instant::now() + debounce);
53+ }
54+ }
55+ _ = state.rescan.notified() => {
56+ pending.insert(Task::Rescan, Instant::now());
57+ }
58+ _ = async { poll.as_mut().unwrap().tick().await }, if poll.is_some() => {
59+ pending.entry(Task::Rescan).or_insert_with(Instant::now);
60+ }
61+ _ = tokio::time::sleep_until(next_due.unwrap_or_else(Instant::now)), if next_due.is_some() => {
62+ let now = Instant::now();
63+ let due: Vec<Task> = pending
64+ .iter()
65+ .filter(|(_, at)| **at <= now)
66+ .map(|(task, _)| task.clone())
67+ .collect();
68+ for task in &due {
69+ pending.remove(task);
70+ }
71+ if due.contains(&Task::Rescan) {
72+ // A full scan covers every per-repo refresh.
73+ pending.retain(|task, _| *task == Task::Rescan);
74+ let st = state.clone();
75+ let stats = tokio::task::spawn_blocking(move || {
76+ let config = st.config.load_full();
77+ st.index.full_scan(&config)
78+ })
79+ .await;
80+ if let Ok(stats) = stats {
81+ let index = state.index.load();
82+ log_scan(&stats, &index, &mut seen, false);
83+ fs.sync(&index);
84+ }
85+ continue;
86+ }
87+ for task in due {
88+ let Task::Repo(git_dir) = task else { continue };
89+ let st = state.clone();
90+ let outcome = tokio::task::spawn_blocking(move || {
91+ let config = st.config.load_full();
92+ st.index.refresh(&config, &git_dir)
93+ })
94+ .await;
95+ match outcome {
96+ Ok(Refresh::Updated(name)) => tracing::info!("updated {name}"),
97+ Ok(Refresh::Hidden(name)) => tracing::info!("{name} is now hidden"),
98+ Ok(Refresh::NeedsRescan) => {
99+ pending.insert(Task::Rescan, Instant::now());
100+ }
101+ Ok(Refresh::Failed(e)) => tracing::warn!("refresh failed: {e}"),
102+ Ok(Refresh::Unchanged) | Err(_) => {}
103+ }
104+ }
105+ }
106+ }
107+ }
108+}
109+
110+fn poll_timer(secs: u64) -> Option<Interval> {
111+ (secs > 0).then(|| {
112+ let period = Duration::from_secs(secs);
113+ let mut interval = tokio::time::interval_at(Instant::now() + period, period);
114+ interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
115+ interval
116+ })
117+}
118+
119+/// Map a filesystem event to the work it implies.
120+fn classify(event: notify::Result<Event>, index: &RepoIndex) -> Vec<Task> {
121+ let event = match event {
122+ Ok(event) => event,
123+ Err(e) => {
124+ tracing::warn!("watch error: {e}; rescanning");
125+ return vec![Task::Rescan];
126+ }
127+ };
128+ if event.need_rescan() {
129+ return vec![Task::Rescan];
130+ }
131+ if matches!(event.kind, EventKind::Access(_)) {
132+ return Vec::new();
133+ }
134+ let mut tasks = Vec::new();
135+ for path in &event.paths {
136+ if path.extension().is_some_and(|ext| ext == "lock") {
137+ continue;
138+ }
139+ let task = if let Some(git_dir) = path.ancestors().find(|a| index.git_dirs.contains_key(*a))
140+ {
141+ Task::Repo(git_dir.to_path_buf())
142+ } else if index
143+ .dirs
144+ .iter()
145+ .any(|d| d == path || Some(d.as_path()) == path.parent())
146+ {
147+ Task::Rescan
148+ } else {
149+ continue;
150+ };
151+ if !tasks.contains(&task) {
152+ tasks.push(task);
153+ }
154+ }
155+ tasks
156+}
157+
158+/// Log a scan: a summary when something changed (always for the initial
159+/// scan) and each warning once until it goes away.
160+pub fn log_scan(stats: &ScanStats, index: &RepoIndex, seen: &mut BTreeSet<String>, initial: bool) {
161+ let ms = stats.elapsed.as_secs_f64() * 1000.0;
162+ if initial {
163+ tracing::info!(
164+ "indexed {} repositories ({} hidden) in {ms:.1} ms",
165+ stats.total,
166+ stats.hidden
167+ );
168+ } else if stats.any_change() {
169+ tracing::info!(
170+ "rescan: {} repositories (+{} −{} ~{}) in {ms:.1} ms",
171+ stats.total,
172+ stats.added,
173+ stats.removed,
174+ stats.changed
175+ );
176+ } else {
177+ tracing::debug!("rescan: no changes ({ms:.1} ms)");
178+ }
179+ let current: BTreeSet<String> = index.warnings.iter().cloned().collect();
180+ for warning in current.difference(seen) {
181+ tracing::warn!("{warning}");
182+ }
183+ *seen = current;
184+}
185+
186+/// The inotify watcher and the set of paths it currently watches.
187+struct FsWatch {
188+ watcher: Option<RecommendedWatcher>,
189+ watched: HashMap<PathBuf, RecursiveMode>,
190+ warned_limit: bool,
191+}
192+
193+impl FsWatch {
194+ fn new(state: &AppState, tx: mpsc::UnboundedSender<notify::Result<Event>>) -> Self {
195+ let watcher = if state.config.load().watch.enabled {
196+ match notify::recommended_watcher(move |event| {
197+ let _ = tx.send(event);
198+ }) {
199+ Ok(w) => Some(w),
200+ Err(e) => {
201+ tracing::warn!("cannot start file watcher ({e}); relying on periodic rescans");
202+ None
203+ }
204+ }
205+ } else {
206+ None
207+ };
208+ FsWatch {
209+ watcher,
210+ watched: HashMap::new(),
211+ warned_limit: false,
212+ }
213+ }
214+
215+ /// Bring the watched set in line with the index.
216+ fn sync(&mut self, index: &RepoIndex) {
217+ let Some(watcher) = self.watcher.as_mut() else {
218+ return;
219+ };
220+ let mut desired: HashMap<PathBuf, RecursiveMode> = HashMap::new();
221+ for dir in &index.dirs {
222+ desired.insert(dir.clone(), RecursiveMode::NonRecursive);
223+ }
224+ for git_dir in index.git_dirs.keys() {
225+ desired.insert(git_dir.clone(), RecursiveMode::NonRecursive);
226+ desired.insert(git_dir.join("refs"), RecursiveMode::Recursive);
227+ }
228+
229+ let stale: Vec<PathBuf> = self
230+ .watched
231+ .iter()
232+ .filter(|(path, mode)| desired.get(*path) != Some(*mode))
233+ .map(|(path, _)| path.clone())
234+ .collect();
235+ for path in stale {
236+ let _ = watcher.unwatch(&path);
237+ self.watched.remove(&path);
238+ }
239+ for (path, mode) in desired {
240+ if self.watched.contains_key(&path) {
241+ continue;
242+ }
243+ match watcher.watch(&path, mode) {
244+ Ok(()) => {
245+ self.watched.insert(path, mode);
246+ }
247+ Err(e) => watch_failed(&mut self.warned_limit, &path, e),
248+ }
249+ }
250+ }
251+}
252+
253+fn watch_failed(warned_limit: &mut bool, path: &Path, e: notify::Error) {
254+ // ENOSPC from inotify_add_watch means the per-user watch limit.
255+ let limit = matches!(&e.kind, notify::ErrorKind::MaxFilesWatch)
256+ || matches!(&e.kind, notify::ErrorKind::Io(io) if io.raw_os_error() == Some(28));
257+ if !limit {
258+ tracing::debug!("cannot watch {}: {e}", path.display());
259+ } else if !*warned_limit {
260+ *warned_limit = true;
261+ tracing::warn!(
262+ "inotify watch limit reached; raise fs.inotify.max_user_watches \
263+ (changes are still picked up by periodic rescans)"
264+ );
265+ }
266+}
A src/lib.rs View
@@ -0,0 +1,12 @@
1+//! Ferrenium: a fast, read-only Git repository browser.
2+
3+pub mod cache;
4+pub mod config;
5+pub mod examples;
6+pub mod git;
7+pub mod handlers;
8+pub mod index;
9+pub mod logging;
10+pub mod render;
11+pub mod server;
12+pub mod state;
A src/logging.rs View
@@ -0,0 +1,56 @@
1+//! stderr logging. Under systemd (`JOURNAL_STREAM` set) timestamps and colors
2+//! are dropped because journald adds its own. `RUST_LOG` overrides
3+//! `log.level`; otherwise the level follows the config across reloads.
4+
5+use std::io::IsTerminal;
6+use std::sync::OnceLock;
7+
8+use tracing_subscriber::prelude::*;
9+use tracing_subscriber::{EnvFilter, Registry, reload};
10+
11+use crate::config::Config;
12+
13+static HANDLE: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
14+
15+pub fn init(config: &Config) {
16+ let (filter, handle) = reload::Layer::new(filter_for(config));
17+ let fmt = tracing_subscriber::fmt::layer()
18+ .with_writer(std::io::stderr)
19+ .with_target(false);
20+ let registry = tracing_subscriber::registry().with(filter);
21+ if std::env::var_os("JOURNAL_STREAM").is_some() {
22+ registry.with(fmt.without_time().with_ansi(false)).init();
23+ } else {
24+ registry
25+ .with(fmt.with_ansi(std::io::stderr().is_terminal()))
26+ .init();
27+ }
28+ let _ = HANDLE.set(handle);
29+}
30+
31+/// Apply `log.level` from a freshly loaded config.
32+pub fn apply(config: &Config) {
33+ if rust_log().is_some() {
34+ return;
35+ }
36+ if let Some(handle) = HANDLE.get() {
37+ let _ = handle.reload(filter_for(config));
38+ }
39+}
40+
41+fn rust_log() -> Option<String> {
42+ std::env::var("RUST_LOG")
43+ .ok()
44+ .filter(|v| !v.trim().is_empty())
45+}
46+
47+fn filter_for(config: &Config) -> EnvFilter {
48+ if let Some(spec) = rust_log() {
49+ match EnvFilter::try_new(&spec) {
50+ Ok(filter) => return filter,
51+ Err(e) => eprintln!("ignoring invalid RUST_LOG {spec:?}: {e}"),
52+ }
53+ }
54+ // Validated at config load time.
55+ EnvFilter::try_new(&config.log.level).unwrap_or_else(|_| EnvFilter::new("info"))
56+}
A src/main.rs View
@@ -0,0 +1,183 @@
1+use std::path::PathBuf;
2+use std::process::ExitCode;
3+
4+use anyhow::Context;
5+use clap::{Parser, Subcommand, ValueEnum};
6+
7+use ferrenium::config::{self, Config, Listen};
8+use ferrenium::index::Indexer;
9+use ferrenium::render::time;
10+use ferrenium::state::{AppState, Overrides};
11+use ferrenium::{examples, logging, server};
12+
13+#[derive(Parser)]
14+#[command(
15+ name = "ferrenium",
16+ version,
17+ about = "A fast, read-only Git repository browser"
18+)]
19+struct Cli {
20+ #[command(subcommand)]
21+ command: Command,
22+}
23+
24+#[derive(Subcommand)]
25+enum Command {
26+ /// Run the server in the foreground (this is what systemd runs).
27+ Serve {
28+ /// Config file [default: /etc/ferrenium/ferrenium.toml, then
29+ /// $XDG_CONFIG_HOME/ferrenium/ferrenium.toml]
30+ #[arg(long, env = "FERRENIUM_CONFIG")]
31+ config: Option<PathBuf>,
32+ /// Override server.listen (HOST:PORT or unix:/path/to.sock).
33+ #[arg(long)]
34+ listen: Option<Listen>,
35+ },
36+ /// Validate the config and report what would be served.
37+ Check {
38+ #[arg(long, env = "FERRENIUM_CONFIG")]
39+ config: Option<PathBuf>,
40+ },
41+ /// Print a ready-to-use template to stdout.
42+ Example {
43+ #[arg(value_enum)]
44+ kind: ExampleKind,
45+ },
46+}
47+
48+#[derive(Clone, Copy, ValueEnum)]
49+enum ExampleKind {
50+ /// ferrenium.toml with every option.
51+ Config,
52+ /// Sandboxed systemd unit.
53+ Systemd,
54+ /// Caddy site block.
55+ Caddy,
56+}
57+
58+fn main() -> ExitCode {
59+ let cli = Cli::parse();
60+ let result = match cli.command {
61+ Command::Serve { config, listen } => serve(config, listen),
62+ Command::Check { config } => check(config),
63+ Command::Example { kind } => {
64+ print!(
65+ "{}",
66+ match kind {
67+ ExampleKind::Config => examples::CONFIG,
68+ ExampleKind::Systemd => examples::SYSTEMD,
69+ ExampleKind::Caddy => examples::CADDY,
70+ }
71+ );
72+ Ok(())
73+ }
74+ };
75+ match result {
76+ Ok(()) => ExitCode::SUCCESS,
77+ Err(e) => {
78+ eprintln!("error: {e:#}");
79+ ExitCode::FAILURE
80+ }
81+ }
82+}
83+
84+fn serve(config_path: Option<PathBuf>, listen: Option<Listen>) -> anyhow::Result<()> {
85+ let overrides = Overrides {
86+ config_path: config::resolve_path(config_path.as_deref()),
87+ listen,
88+ };
89+ let config = overrides.load()?;
90+ logging::init(&config);
91+ match &config.source {
92+ Some(path) => tracing::info!("config: {}", path.display()),
93+ None => tracing::warn!("no config file found; using built-in defaults"),
94+ }
95+
96+ let state = AppState::new(config, overrides);
97+ tokio::runtime::Builder::new_multi_thread()
98+ .enable_all()
99+ .thread_name("ferrenium")
100+ .build()
101+ .context("cannot start async runtime")?
102+ .block_on(server::run(state))
103+}
104+
105+fn check(config_path: Option<PathBuf>) -> anyhow::Result<()> {
106+ let path = config::resolve_path(config_path.as_deref());
107+ let config = Config::load(path.as_deref())?;
108+ let mut warnings = Vec::new();
109+
110+ let source = match &config.source {
111+ Some(p) => p.display().to_string(),
112+ None => {
113+ warnings.push("no config file found; built-in defaults apply".to_string());
114+ "(built-in defaults)".to_string()
115+ }
116+ };
117+ println!("config {source}");
118+ println!("listen {}", config.server.listen);
119+ println!("base_path {}", config.server.base_path);
120+ if let Some(url) = &config.server.base_url {
121+ println!("base_url {url}");
122+ }
123+ println!(
124+ "scan_path {} (max depth {})",
125+ config.repos.scan_path.display(),
126+ config.repos.max_depth
127+ );
128+ if !config.repos.scan_path.is_dir() {
129+ warnings.push(format!(
130+ "repos.scan_path {} is not a readable directory",
131+ config.repos.scan_path.display()
132+ ));
133+ }
134+ if config.server.base_url.is_none() {
135+ warnings.push("server.base_url is not set; feeds and clone URLs will be relative".into());
136+ }
137+
138+ let indexer = Indexer::new();
139+ let stats = indexer.full_scan(&config);
140+ let index = indexer.load();
141+ println!(
142+ "\nrepositories {} visible, {} hidden (scanned in {:.1} ms)",
143+ stats.total,
144+ stats.hidden,
145+ stats.elapsed.as_secs_f64() * 1000.0
146+ );
147+ if !index.repos.is_empty() {
148+ let width = index
149+ .repos
150+ .keys()
151+ .map(|n| n.chars().count())
152+ .max()
153+ .unwrap_or(0);
154+ let now = time::now();
155+ for repo in index.repos.values() {
156+ let m = &repo.meta;
157+ println!(
158+ " {:<width$} {:<12} {:>3} br {:>3} tags {:<14} {}",
159+ repo.name,
160+ m.default_branch.as_deref().unwrap_or("-"),
161+ m.branches,
162+ m.tags,
163+ m.last_activity
164+ .map_or_else(|| "empty".into(), |t| time::relative(t, now)),
165+ m.section
166+ .as_deref()
167+ .map(|s| format!("[{s}]"))
168+ .unwrap_or_default(),
169+ );
170+ }
171+ }
172+ for name in &index.hidden {
173+ println!(" {name} (hidden)");
174+ }
175+ warnings.extend(index.warnings.iter().cloned());
176+
177+ println!();
178+ for w in &warnings {
179+ println!("warning: {w}");
180+ }
181+ println!("ok");
182+ Ok(())
183+}
A src/render/highlight.rs View
@@ -0,0 +1,252 @@
1+//! Syntax highlighting into monochrome CSS classes.
2+//!
3+//! The whole file is parsed in one pass (so multi-line strings and comments
4+//! are right), but the output is split into lines whose `<span>`s are
5+//! balanced: each line can be put in its own table row.
6+//!
7+//! Scopes collapse to a handful of classes (see `syntax.css`):
8+//! `hl-k` keywords/storage, `hl-d` names/numbers/constants, `hl-s`
9+//! strings/punctuation/operators, `hl-c` comments, `hl-x` invalid.
10+
11+use std::sync::OnceLock;
12+
13+use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet};
14+use syntect::util::LinesWithEndings;
15+
16+use super::escape_html;
17+
18+static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new();
19+
20+/// The embedded syntax set (bat's ~200 syntaxes). Loading takes a few tens
21+/// of milliseconds; call once at startup to keep it off the request path.
22+pub fn syntaxes() -> &'static SyntaxSet {
23+ SYNTAXES.get_or_init(two_face::syntax::extra_newlines)
24+}
25+
26+/// Pick a syntax from the file name, falling back to the first line
27+/// (shebangs, modelines). `None` means plain text.
28+pub fn detect(path: &str, first_line: &str) -> Option<&'static SyntaxReference> {
29+ let set = syntaxes();
30+ let name = path.rsplit('/').next().unwrap_or(path);
31+ let found = set
32+ .find_syntax_by_extension(name)
33+ .or_else(|| {
34+ let (_, ext) = name.rsplit_once('.')?;
35+ set.find_syntax_by_extension(ext)
36+ .or_else(|| set.find_syntax_by_extension(&ext.to_ascii_lowercase()))
37+ })
38+ .or_else(|| set.find_syntax_by_first_line(first_line));
39+ found.filter(|s| s.name != "Plain Text")
40+}
41+
42+/// Syntax for a Markdown code fence info string (`rust`, `sh`, `py`, …).
43+pub fn for_token(token: &str) -> Option<&'static SyntaxReference> {
44+ let token = token.split([' ', ',', '{']).next()?.trim();
45+ if token.is_empty() {
46+ return None;
47+ }
48+ syntaxes()
49+ .find_syntax_by_token(token)
50+ .filter(|s| s.name != "Plain Text")
51+}
52+
53+struct Selectors {
54+ comment: Scope,
55+ invalid: Scope,
56+ string: Scope,
57+ punctuation: Scope,
58+ operator: Scope,
59+ keyword: Scope,
60+ storage: Scope,
61+ display: [Scope; 6],
62+}
63+
64+fn selectors() -> &'static Selectors {
65+ static SELECTORS: OnceLock<Selectors> = OnceLock::new();
66+ SELECTORS.get_or_init(|| {
67+ let s = |name: &str| Scope::new(name).expect("valid scope");
68+ Selectors {
69+ comment: s("comment"),
70+ invalid: s("invalid"),
71+ string: s("string"),
72+ punctuation: s("punctuation"),
73+ operator: s("keyword.operator"),
74+ keyword: s("keyword"),
75+ storage: s("storage"),
76+ display: [
77+ s("entity.name"),
78+ s("support.function"),
79+ s("support.type"),
80+ s("support.class"),
81+ s("constant.numeric"),
82+ s("constant.language"),
83+ ],
84+ }
85+ })
86+}
87+
88+/// Class for text under `stack`: comments win outright, otherwise the
89+/// innermost scope with a class decides.
90+fn class_for(stack: &[Scope]) -> Option<&'static str> {
91+ let sel = selectors();
92+ if stack.iter().any(|s| sel.comment.is_prefix_of(*s)) {
93+ return Some("hl-c");
94+ }
95+ for &scope in stack.iter().rev() {
96+ if sel.invalid.is_prefix_of(scope) {
97+ return Some("hl-x");
98+ }
99+ if sel.operator.is_prefix_of(scope)
100+ || sel.punctuation.is_prefix_of(scope)
101+ || sel.string.is_prefix_of(scope)
102+ {
103+ return Some("hl-s");
104+ }
105+ if sel.keyword.is_prefix_of(scope) || sel.storage.is_prefix_of(scope) {
106+ return Some("hl-k");
107+ }
108+ if sel.display.iter().any(|d| d.is_prefix_of(scope)) {
109+ return Some("hl-d");
110+ }
111+ }
112+ None
113+}
114+
115+/// Appends text to a line, opening/closing spans only when the class changes.
116+struct LineWriter {
117+ out: String,
118+ open: Option<&'static str>,
119+}
120+
121+impl LineWriter {
122+ fn push(&mut self, text: &str, class: Option<&'static str>) {
123+ let text = text.trim_end_matches(['\n', '\r']);
124+ if text.is_empty() {
125+ return;
126+ }
127+ if class != self.open {
128+ if self.open.is_some() {
129+ self.out.push_str("</span>");
130+ }
131+ if let Some(class) = class {
132+ self.out.push_str("<span class=\"");
133+ self.out.push_str(class);
134+ self.out.push_str("\">");
135+ }
136+ self.open = class;
137+ }
138+ escape_html(&mut self.out, text);
139+ }
140+
141+ fn finish(mut self) -> String {
142+ if self.open.is_some() {
143+ self.out.push_str("</span>");
144+ }
145+ self.out
146+ }
147+}
148+
149+/// Highlight `text` and return one HTML fragment per line.
150+///
151+/// If the grammar fails part-way, the remaining lines are emitted plain.
152+pub fn lines(text: &str, syntax: &SyntaxReference) -> Vec<String> {
153+ let set = syntaxes();
154+ let mut state = ParseState::new(syntax);
155+ let mut stack = ScopeStack::new();
156+ let mut failed = false;
157+ let mut out = Vec::new();
158+ for line in LinesWithEndings::from(text) {
159+ let ops = if failed {
160+ None
161+ } else {
162+ state.parse_line(line, set).ok()
163+ };
164+ let Some(ops) = ops else {
165+ failed = true;
166+ out.push(plain_line(line));
167+ continue;
168+ };
169+ let mut writer = LineWriter {
170+ out: String::with_capacity(line.len() * 2),
171+ open: None,
172+ };
173+ let mut pos = 0;
174+ for (at, op) in ops {
175+ let at = at.min(line.len());
176+ if at > pos {
177+ writer.push(&line[pos..at], class_for(stack.as_slice()));
178+ pos = at;
179+ }
180+ if stack.apply(&op).is_err() {
181+ failed = true;
182+ }
183+ }
184+ writer.push(&line[pos..], class_for(stack.as_slice()));
185+ out.push(writer.finish());
186+ }
187+ out
188+}
189+
190+/// Escaped lines without highlighting.
191+pub fn plain_lines(text: &str) -> Vec<String> {
192+ LinesWithEndings::from(text).map(plain_line).collect()
193+}
194+
195+fn plain_line(line: &str) -> String {
196+ let mut out = String::with_capacity(line.len());
197+ escape_html(&mut out, line.trim_end_matches(['\n', '\r']));
198+ out
199+}
200+
201+#[cfg(test)]
202+mod tests {
203+ use super::*;
204+
205+ #[test]
206+ fn detects_by_name_extension_and_first_line() {
207+ assert_eq!(detect("src/main.rs", "").unwrap().name, "Rust");
208+ assert_eq!(detect("Makefile", "").unwrap().name, "Makefile");
209+ assert_eq!(detect("a/b.PY", "").unwrap().name, "Python");
210+ assert_eq!(
211+ detect("run", "#!/bin/bash").unwrap().name,
212+ "Bourne Again Shell (bash)"
213+ );
214+ assert!(detect("notes.txt", "hello").is_none());
215+ assert_eq!(for_token("rust").unwrap().name, "Rust");
216+ assert_eq!(for_token("sh").unwrap().name, "Bourne Again Shell (bash)");
217+ assert!(for_token("").is_none());
218+ }
219+
220+ #[test]
221+ fn spans_are_balanced_per_line_across_multiline_comments() {
222+ let src = "fn main() {\n /* a\n b */ let x = \"<s>\";\n}\n";
223+ let lines = lines(src, detect("x.rs", "").unwrap());
224+ assert_eq!(lines.len(), 4);
225+ for line in &lines {
226+ assert_eq!(
227+ line.matches("<span").count(),
228+ line.matches("</span>").count(),
229+ "{line}"
230+ );
231+ }
232+ assert!(
233+ lines[0].contains(r#"<span class="hl-k">fn</span>"#),
234+ "{}",
235+ lines[0]
236+ );
237+ // The comment's second line is still a comment.
238+ assert!(
239+ lines[2].starts_with(r#"<span class="hl-c"> b */"#),
240+ "{}",
241+ lines[2]
242+ );
243+ // Escaped string contents.
244+ assert!(lines[2].contains("&lt;s&gt;"), "{}", lines[2]);
245+ assert!(!lines.iter().any(|l| l.contains('\n')));
246+ }
247+
248+ #[test]
249+ fn plain_lines_escape() {
250+ assert_eq!(plain_lines("a<b\r\n\nc"), vec!["a&lt;b", "", "c"]);
251+ }
252+}
A src/render/markdown.rs View
@@ -0,0 +1,314 @@
1+//! Markdown (GFM) to sanitized HTML.
2+//!
3+//! comrak renders with raw HTML allowed; ammonia then sanitizes everything,
4+//! including that raw HTML. Relative links and images (from Markdown or
5+//! raw HTML alike) are rewritten to point into the repository: links to the
6+//! blob view, images to the raw endpoint.
7+
8+use std::borrow::Cow;
9+use std::collections::{HashMap, HashSet};
10+use std::fmt;
11+
12+use comrak::adapters::SyntaxHighlighterAdapter;
13+use comrak::options::Plugins;
14+use comrak::{Options, markdown_to_html_with_plugins};
15+
16+use super::{encode_bytes, escape_html, highlight, percent_decode};
17+
18+/// Where relative URLs in a document resolve to.
19+#[derive(Debug, Clone, PartialEq, Eq)]
20+pub struct Links {
21+ /// URL of the repository, without a trailing slash (`/git/forks/foo`).
22+ pub repo_url: String,
23+ /// Revision as it appears in URLs (already percent-encoded).
24+ pub rev: String,
25+ /// Directory of the document inside the tree (no slashes at the ends;
26+ /// empty for the root).
27+ pub dir: Vec<u8>,
28+}
29+
30+impl Links {
31+ /// Cache-key fragment identifying everything the output depends on.
32+ pub fn key(&self) -> String {
33+ format!(
34+ "{}\0{}\0{}",
35+ self.repo_url,
36+ self.rev,
37+ String::from_utf8_lossy(&self.dir)
38+ )
39+ }
40+}
41+
42+/// Is this a Markdown file (by name)?
43+pub fn is_markdown(path: &str) -> bool {
44+ let lower = path.to_ascii_lowercase();
45+ [".md", ".markdown", ".mdown", ".mkd", ".mkdn"]
46+ .iter()
47+ .any(|ext| lower.ends_with(ext))
48+}
49+
50+pub fn render(markdown: &str, links: &Links) -> String {
51+ let mut options = Options::default();
52+ let ext = &mut options.extension;
53+ ext.strikethrough = true;
54+ ext.table = true;
55+ ext.autolink = true;
56+ ext.tasklist = true;
57+ ext.footnotes = true;
58+ ext.alerts = true;
59+ ext.header_id_prefix = Some(String::new());
60+ ext.front_matter_delimiter = Some("---".into());
61+ options.render.r#unsafe = true;
62+ options.render.github_pre_lang = false;
63+
64+ let mut plugins = Plugins::default();
65+ let highlighter = FenceHighlighter;
66+ plugins.render.codefence_syntax_highlighter = Some(&highlighter);
67+ let html = markdown_to_html_with_plugins(markdown, &options, &plugins);
68+ sanitize(&html, links.clone())
69+}
70+
71+/// Plain text (non-Markdown READMEs) as an escaped `<pre>`.
72+pub fn render_plain(text: &str) -> String {
73+ let mut out = String::from("<pre class=\"plain\">");
74+ escape_html(&mut out, text);
75+ out.push_str("</pre>");
76+ out
77+}
78+
79+fn sanitize(html: &str, links: Links) -> String {
80+ let mut builder = ammonia::Builder::default();
81+ builder
82+ .add_tags(["input"])
83+ .add_tag_attributes("input", ["checked"])
84+ .set_tag_attribute_value("input", "type", "checkbox")
85+ .set_tag_attribute_value("input", "disabled", "")
86+ .add_generic_attributes(["id", "align"])
87+ .add_tag_attributes("a", ["aria-hidden"])
88+ .add_tag_attributes("img", ["title"])
89+ .add_tag_attributes("ol", ["start"])
90+ .add_tag_attributes("section", ["data-footnotes"])
91+ .add_tags(["section"])
92+ .link_rel(Some("noopener noreferrer nofollow"))
93+ .allowed_classes(allowed_classes())
94+ .url_relative(ammonia::UrlRelative::PassThrough)
95+ .attribute_filter(move |element, attribute, value| {
96+ match (element, attribute) {
97+ ("a", "href") => rewrite(&links, value, "blob"),
98+ ("img", "src") => rewrite(&links, value, "raw"),
99+ _ => None,
100+ }
101+ .map_or(Some(Cow::Borrowed(value)), |v| Some(Cow::Owned(v)))
102+ });
103+ builder.clean(html).to_string()
104+}
105+
106+fn allowed_classes() -> HashMap<&'static str, HashSet<&'static str>> {
107+ let set = |items: &[&'static str]| items.iter().copied().collect::<HashSet<_>>();
108+ let alerts = [
109+ "markdown-alert",
110+ "markdown-alert-note",
111+ "markdown-alert-tip",
112+ "markdown-alert-important",
113+ "markdown-alert-warning",
114+ "markdown-alert-caution",
115+ ];
116+ HashMap::from([
117+ ("span", set(&["hl-k", "hl-d", "hl-s", "hl-c", "hl-x"])),
118+ ("div", set(&alerts)),
119+ ("p", set(&["markdown-alert-title"])),
120+ ("a", set(&["anchor"])),
121+ ("section", set(&["footnotes"])),
122+ ("ul", set(&["contains-task-list"])),
123+ ("li", set(&["task-list-item"])),
124+ ])
125+}
126+
127+/// `scheme:` per the URL spec, before any `/`, `?` or `#`.
128+fn has_scheme(url: &str) -> bool {
129+ let Some(colon) = url.find(':') else {
130+ return false;
131+ };
132+ let scheme = &url[..colon];
133+ scheme
134+ .chars()
135+ .next()
136+ .is_some_and(|c| c.is_ascii_alphabetic())
137+ && scheme
138+ .chars()
139+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
140+}
141+
142+/// Rewrite a repository-relative URL; `None` leaves the value unchanged.
143+fn rewrite(links: &Links, url: &str, view: &str) -> Option<String> {
144+ if url.is_empty() || url.starts_with('#') || url.starts_with("//") || has_scheme(url) {
145+ return None;
146+ }
147+ let (rest, fragment) = match url.split_once('#') {
148+ Some((rest, f)) => (rest, Some(f)),
149+ None => (url, None),
150+ };
151+ let path = rest.split_once('?').map_or(rest, |(p, _)| p);
152+ let path = percent_decode(path);
153+
154+ let mut parts: Vec<&[u8]> = Vec::new();
155+ if !path.starts_with(b"/") {
156+ parts.extend(links.dir.split(|b| *b == b'/').filter(|p| !p.is_empty()));
157+ }
158+ for part in path.split(|b| *b == b'/') {
159+ match part {
160+ b"" | b"." => {}
161+ b".." => {
162+ parts.pop();
163+ }
164+ part => parts.push(part),
165+ }
166+ }
167+ let joined = parts.join(&b'/');
168+ let mut out = if joined.is_empty() {
169+ format!("{}/tree/{}", links.repo_url, links.rev)
170+ } else {
171+ format!(
172+ "{}/{view}/{}/{}",
173+ links.repo_url,
174+ links.rev,
175+ encode_bytes(&joined)
176+ )
177+ };
178+ if let Some(fragment) = fragment {
179+ out.push('#');
180+ out.push_str(fragment);
181+ }
182+ Some(out)
183+}
184+
185+/// Highlights fenced code blocks with the same classes as the blob view.
186+struct FenceHighlighter;
187+
188+impl SyntaxHighlighterAdapter for FenceHighlighter {
189+ fn write_highlighted(
190+ &self,
191+ output: &mut dyn fmt::Write,
192+ lang: Option<&str>,
193+ code: &str,
194+ ) -> fmt::Result {
195+ match lang.and_then(highlight::for_token) {
196+ Some(syntax) => output.write_str(&highlight::lines(code, syntax).join("\n")),
197+ None => {
198+ let mut escaped = String::with_capacity(code.len());
199+ escape_html(&mut escaped, code.trim_end_matches('\n'));
200+ output.write_str(&escaped)
201+ }
202+ }
203+ }
204+
205+ fn write_pre_tag(
206+ &self,
207+ output: &mut dyn fmt::Write,
208+ _attributes: HashMap<&'static str, Cow<'_, str>>,
209+ ) -> fmt::Result {
210+ output.write_str("<pre>")
211+ }
212+
213+ fn write_code_tag(
214+ &self,
215+ output: &mut dyn fmt::Write,
216+ _attributes: HashMap<&'static str, Cow<'_, str>>,
217+ ) -> fmt::Result {
218+ output.write_str("<code>")
219+ }
220+}
221+
222+#[cfg(test)]
223+mod tests {
224+ use super::*;
225+
226+ fn links(dir: &str) -> Links {
227+ Links {
228+ repo_url: "/r".into(),
229+ rev: "main".into(),
230+ dir: dir.as_bytes().to_vec(),
231+ }
232+ }
233+
234+ #[test]
235+ fn strips_dangerous_html() {
236+ let html = render(
237+ "<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n\
238+ [x](javascript:alert(1)) <a href=\"data:text/html,hi\">d</a>\n\n\
239+ <iframe src=\"https://evil\"></iframe><style>body{}</style>\n\n\
240+ <div style=\"color:red\" onclick=\"x()\">s</div>",
241+ &links(""),
242+ );
243+ for bad in [
244+ "<script",
245+ "onerror",
246+ "javascript:",
247+ "data:text",
248+ "<iframe",
249+ "<style",
250+ "onclick",
251+ "style=",
252+ ] {
253+ assert!(!html.contains(bad), "{bad} survived: {html}");
254+ }
255+ }
256+
257+ #[test]
258+ fn rewrites_relative_urls() {
259+ let html = render(
260+ "[a](docs/guide.md#install) [b](../up.md) [c](/root.md) [d](https://x.org/y) \
261+ [e](#frag) [f](./) ![i](img/logo.png) <img src=\"pic%20s.svg\"> \
262+ <a href=\"sub/\">raw</a>",
263+ &links("docs"),
264+ );
265+ assert!(
266+ html.contains(r#"href="/r/blob/main/docs/docs/guide.md#install""#),
267+ "{html}"
268+ );
269+ assert!(html.contains(r#"href="/r/blob/main/up.md""#), "{html}");
270+ assert!(html.contains(r#"href="/r/blob/main/root.md""#), "{html}");
271+ assert!(html.contains(r#"href="https://x.org/y""#), "{html}");
272+ assert!(html.contains(r##"href="#frag""##), "{html}");
273+ assert!(html.contains(r#"href="/r/blob/main/docs""#), "{html}");
274+ assert!(
275+ html.contains(r#"src="/r/raw/main/docs/img/logo.png""#),
276+ "{html}"
277+ );
278+ assert!(
279+ html.contains(r#"src="/r/raw/main/docs/pic%20s.svg""#),
280+ "{html}"
281+ );
282+ assert!(html.contains(r#"href="/r/blob/main/docs/sub""#), "{html}");
283+ // `..` can't escape the repository root.
284+ assert_eq!(
285+ rewrite(&links(""), "../../x", "blob").unwrap(),
286+ "/r/blob/main/x"
287+ );
288+ assert_eq!(rewrite(&links(""), "..", "blob").unwrap(), "/r/tree/main");
289+ }
290+
291+ #[test]
292+ fn gfm_features_survive_sanitizing() {
293+ let html = render(
294+ "# Title\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n\
295+ > [!NOTE]\n> careful\n\nfoot[^1]\n\n[^1]: note\n\n```rust\nfn x() {}\n```\n",
296+ &links(""),
297+ );
298+ assert!(html.contains("<table>"), "{html}");
299+ assert!(html.contains(r#"type="checkbox""#), "{html}");
300+ assert!(html.contains("markdown-alert-note"), "{html}");
301+ assert!(html.contains(r#"id="title""#), "{html}");
302+ assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}");
303+ assert!(html.contains("footnote"), "{html}");
304+ }
305+
306+ #[test]
307+ fn scheme_detection() {
308+ assert!(has_scheme("https://x"));
309+ assert!(has_scheme("mailto:a@b"));
310+ assert!(!has_scheme("a/b:c"));
311+ assert!(!has_scheme("x.md"));
312+ assert!(!has_scheme(":x"));
313+ }
314+}
A src/render/mod.rs View
@@ -0,0 +1,115 @@
1+//! Presentation helpers shared by handlers and templates.
2+
3+pub mod highlight;
4+pub mod markdown;
5+pub mod time;
6+
7+use std::fmt::Write;
8+
9+/// Percent-encode a slash-separated path for use in a URL, keeping `/`.
10+pub fn encode_path(path: &str) -> String {
11+ encode_bytes(path.as_bytes())
12+}
13+
14+/// [`encode_path`] for paths that may not be UTF-8 (git tree paths).
15+pub fn encode_bytes(path: &[u8]) -> String {
16+ let mut out = String::with_capacity(path.len());
17+ for &b in path {
18+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
19+ out.push(b as char);
20+ } else {
21+ let _ = write!(out, "%{b:02X}");
22+ }
23+ }
24+ out
25+}
26+
27+/// Decode `%XX` escapes (invalid escapes are kept literally).
28+pub fn percent_decode(s: &str) -> Vec<u8> {
29+ let bytes = s.as_bytes();
30+ let mut out = Vec::with_capacity(bytes.len());
31+ let mut i = 0;
32+ while i < bytes.len() {
33+ let hex = |b: u8| (b as char).to_digit(16);
34+ if bytes[i] == b'%'
35+ && let (Some(&h), Some(&l)) = (bytes.get(i + 1), bytes.get(i + 2))
36+ && let (Some(h), Some(l)) = (hex(h), hex(l))
37+ {
38+ out.push((h * 16 + l) as u8);
39+ i += 3;
40+ } else {
41+ out.push(bytes[i]);
42+ i += 1;
43+ }
44+ }
45+ out
46+}
47+
48+/// Append `text` to `out` with `&<>"'` escaped.
49+pub fn escape_html(out: &mut String, text: &str) {
50+ for c in text.chars() {
51+ match c {
52+ '&' => out.push_str("&amp;"),
53+ '<' => out.push_str("&lt;"),
54+ '>' => out.push_str("&gt;"),
55+ '"' => out.push_str("&quot;"),
56+ '\'' => out.push_str("&#39;"),
57+ c => out.push(c),
58+ }
59+ }
60+}
61+
62+/// "12.4 KB" (binary multiples, conventional labels).
63+pub fn human_size(bytes: u64) -> String {
64+ const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
65+ if bytes < 1024 {
66+ return format!("{bytes} B");
67+ }
68+ let mut value = bytes as f64 / 1024.0;
69+ let mut unit = 0;
70+ while value >= 1024.0 && unit < UNITS.len() - 1 {
71+ value /= 1024.0;
72+ unit += 1;
73+ }
74+ if value >= 100.0 {
75+ format!("{value:.0} {}", UNITS[unit])
76+ } else {
77+ format!("{value:.1} {}", UNITS[unit])
78+ }
79+}
80+
81+#[cfg(test)]
82+mod tests {
83+ use super::*;
84+
85+ #[test]
86+ fn percent_decoding() {
87+ assert_eq!(percent_decode("a%20b%2Fc"), b"a b/c");
88+ assert_eq!(percent_decode("%C3%BC"), "ü".as_bytes());
89+ assert_eq!(percent_decode("100%"), b"100%");
90+ assert_eq!(percent_decode("%zz%4"), b"%zz%4");
91+ }
92+
93+ #[test]
94+ fn sizes() {
95+ assert_eq!(human_size(0), "0 B");
96+ assert_eq!(human_size(1023), "1023 B");
97+ assert_eq!(human_size(12_698), "12.4 KB");
98+ assert_eq!(human_size(300 * 1024), "300 KB");
99+ assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
100+ }
101+
102+ #[test]
103+ fn escaping() {
104+ let mut s = String::new();
105+ escape_html(&mut s, "<a href=\"x\">&'");
106+ assert_eq!(s, "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
107+ }
108+
109+ #[test]
110+ fn encodes_reserved_but_keeps_slashes() {
111+ assert_eq!(encode_path("forks/foo.rs"), "forks/foo.rs");
112+ assert_eq!(encode_path("a b#?%"), "a%20b%23%3F%25");
113+ assert_eq!(encode_path("ünï"), "%C3%BCn%C3%AF");
114+ }
115+}
A src/render/time.rs View
@@ -0,0 +1,63 @@
1+//! Human-readable times.
2+
3+use std::time::{SystemTime, UNIX_EPOCH};
4+
5+pub fn now() -> i64 {
6+ SystemTime::now()
7+ .duration_since(UNIX_EPOCH)
8+ .map_or(0, |d| d.as_secs() as i64)
9+}
10+
11+/// "3 hours ago", relative to `now` (both unix seconds). Future times
12+/// (clock skew) read as "just now".
13+pub fn relative(then: i64, now: i64) -> String {
14+ const MIN: i64 = 60;
15+ const HOUR: i64 = 60 * MIN;
16+ const DAY: i64 = 24 * HOUR;
17+ const WEEK: i64 = 7 * DAY;
18+ const MONTH: i64 = 30 * DAY;
19+ const YEAR: i64 = 365 * DAY;
20+
21+ let delta = now - then;
22+ let (n, unit) = match delta {
23+ d if d < MIN => return "just now".into(),
24+ d if d < HOUR => (d / MIN, "minute"),
25+ d if d < DAY => (d / HOUR, "hour"),
26+ d if d < 2 * WEEK => (d / DAY, "day"),
27+ d if d < 2 * MONTH => (d / WEEK, "week"),
28+ d if d < YEAR => (d / MONTH, "month"),
29+ d => (d / YEAR, "year"),
30+ };
31+ format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
32+}
33+
34+/// RFC 3339 UTC timestamp, for `<time datetime>` and tooltips.
35+pub fn iso(then: i64) -> String {
36+ jiff::Timestamp::from_second(then)
37+ .map(|t| t.strftime("%Y-%m-%dT%H:%M:%SZ").to_string())
38+ .unwrap_or_default()
39+}
40+
41+#[cfg(test)]
42+mod tests {
43+ use super::*;
44+
45+ #[test]
46+ fn relative_times() {
47+ let now = 1_000_000_000;
48+ assert_eq!(relative(now + 5, now), "just now");
49+ assert_eq!(relative(now - 59, now), "just now");
50+ assert_eq!(relative(now - 60, now), "1 minute ago");
51+ assert_eq!(relative(now - 2 * 3600, now), "2 hours ago");
52+ assert_eq!(relative(now - 86400, now), "1 day ago");
53+ assert_eq!(relative(now - 13 * 86400, now), "13 days ago");
54+ assert_eq!(relative(now - 15 * 86400, now), "2 weeks ago");
55+ assert_eq!(relative(now - 90 * 86400, now), "3 months ago");
56+ assert_eq!(relative(now - 800 * 86400, now), "2 years ago");
57+ }
58+
59+ #[test]
60+ fn iso_format() {
61+ assert_eq!(iso(0), "1970-01-01T00:00:00Z");
62+ }
63+}
A src/server/assets.rs View
@@ -0,0 +1,200 @@
1+//! Embedded static assets served under `/-/static/<hash>/…`.
2+//!
3+//! One hash covers the whole asset set, so files can reference each other
4+//! with relative URLs (`url(../fonts/x.woff2)`) and still be cached forever.
5+//! In debug builds rust-embed reads from disk; responses are then `no-cache`
6+//! so CSS/JS edits show up on reload.
7+
8+use std::hash::{Hash, Hasher};
9+use std::sync::Arc;
10+
11+use axum::body::Bytes;
12+use axum::extract::{Path, State};
13+use axum::http::{HeaderValue, StatusCode, header};
14+use axum::response::{IntoResponse, Response};
15+use rust_embed::RustEmbed;
16+
17+use crate::state::AppState;
18+
19+#[derive(RustEmbed)]
20+#[folder = "assets/"]
21+struct Embedded;
22+
23+/// Parts concatenated (in order) into the virtual `css/app.css`.
24+const CSS_BUNDLE: &[&str] = &[
25+ "css/fonts.css",
26+ "css/tokens.css",
27+ "css/base.css",
28+ "css/components.css",
29+ "css/repo.css",
30+ "css/syntax.css",
31+];
32+const CSS_BUNDLE_PATH: &str = "css/app.css";
33+
34+const IMMUTABLE: &str = "public, max-age=31536000, immutable";
35+const NO_CACHE: &str = "no-cache";
36+
37+pub struct Assets {
38+ hash: String,
39+ /// Prebuilt bundle (release builds only; debug rebuilds per request).
40+ bundle: Option<Bytes>,
41+}
42+
43+impl Assets {
44+ pub fn new() -> Self {
45+ let mut paths: Vec<_> = Embedded::iter().collect();
46+ paths.sort();
47+ // SipHash with fixed keys: deterministic for a given binary, which is
48+ // all a cache-busting token needs.
49+ let mut hasher = std::hash::DefaultHasher::new();
50+ for path in &paths {
51+ path.hash(&mut hasher);
52+ if let Some(file) = Embedded::get(path) {
53+ file.metadata.sha256_hash().hash(&mut hasher);
54+ }
55+ }
56+ let hash = format!("{:016x}", hasher.finish())[..12].to_string();
57+ let bundle = (!cfg!(debug_assertions)).then(build_bundle);
58+ Assets { hash, bundle }
59+ }
60+
61+ pub fn hash(&self) -> &str {
62+ &self.hash
63+ }
64+
65+ /// Bytes for an asset path, including the virtual CSS bundle.
66+ pub fn get(&self, path: &str) -> Option<Bytes> {
67+ if path == CSS_BUNDLE_PATH {
68+ return Some(self.bundle.clone().unwrap_or_else(build_bundle));
69+ }
70+ let file = Embedded::get(path)?;
71+ Some(match file.data {
72+ std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
73+ std::borrow::Cow::Owned(v) => Bytes::from(v),
74+ })
75+ }
76+}
77+
78+impl Default for Assets {
79+ fn default() -> Self {
80+ Self::new()
81+ }
82+}
83+
84+fn build_bundle() -> Bytes {
85+ let mut out = Vec::new();
86+ for part in CSS_BUNDLE {
87+ let file = Embedded::get(part).unwrap_or_else(|| panic!("missing embedded {part}"));
88+ out.extend_from_slice(&file.data);
89+ out.push(b'\n');
90+ }
91+ Bytes::from(out)
92+}
93+
94+pub fn content_type(path: &str) -> HeaderValue {
95+ let mime = mime_guess::from_path(path).first_or_octet_stream();
96+ let value = match (mime.type_(), mime.subtype()) {
97+ (mime_guess::mime::TEXT, _) | (_, mime_guess::mime::JAVASCRIPT) => {
98+ format!("{mime}; charset=utf-8")
99+ }
100+ _ => mime.to_string(),
101+ };
102+ HeaderValue::from_str(&value).unwrap_or(HeaderValue::from_static("application/octet-stream"))
103+}
104+
105+/// `GET /-/static/{hash}/{*path}`
106+pub async fn serve(
107+ State(state): State<Arc<AppState>>,
108+ Path((hash, path)): Path<(String, String)>,
109+) -> Response {
110+ let Some(body) = state.assets.get(&path) else {
111+ return StatusCode::NOT_FOUND.into_response();
112+ };
113+ // A stale hash (e.g. a page rendered before an upgrade) still gets the
114+ // current file, but must not be cached under the old URL.
115+ let cache = if !cfg!(debug_assertions) && hash == state.assets.hash() {
116+ IMMUTABLE
117+ } else {
118+ NO_CACHE
119+ };
120+ (
121+ [
122+ (header::CONTENT_TYPE, content_type(&path)),
123+ (header::CACHE_CONTROL, HeaderValue::from_static(cache)),
124+ ],
125+ body,
126+ )
127+ .into_response()
128+}
129+
130+/// `GET /-/site.css?v=…` — per-site overrides: the configured code font and
131+/// the contents of `site.custom_css`. Only linked when there is something to
132+/// serve; versioned by [`site_css_version`].
133+pub async fn site_css(State(state): State<Arc<AppState>>) -> Response {
134+ let config = state.config.load();
135+ let Some(css) = site_css_body(&config) else {
136+ return StatusCode::NOT_FOUND.into_response();
137+ };
138+ (
139+ [
140+ (
141+ header::CONTENT_TYPE,
142+ HeaderValue::from_static("text/css; charset=utf-8"),
143+ ),
144+ (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
145+ ],
146+ css,
147+ )
148+ .into_response()
149+}
150+
151+pub fn site_css_body(config: &crate::config::Config) -> Option<String> {
152+ let mut css = String::new();
153+ if config.ui.code_font != crate::config::DEFAULT_CODE_FONT {
154+ // The font name was validated to contain no quotes or CSS syntax.
155+ css.push_str(&format!(
156+ ":root {{ --font-code: \"{}\", var(--font-mono); }}\n",
157+ config.ui.code_font
158+ ));
159+ }
160+ if let Some(custom) = &config.custom.css {
161+ css.push_str(custom);
162+ }
163+ (!css.is_empty()).then_some(css)
164+}
165+
166+/// Short content hash for the `?v=` query of the site stylesheet link.
167+pub fn site_css_version(config: &crate::config::Config) -> Option<String> {
168+ let body = site_css_body(config)?;
169+ let mut hasher = std::hash::DefaultHasher::new();
170+ body.hash(&mut hasher);
171+ Some(format!("{:08x}", hasher.finish() as u32))
172+}
173+
174+#[cfg(test)]
175+mod tests {
176+ use super::*;
177+
178+ #[test]
179+ fn bundle_contains_all_parts_and_hash_is_stable() {
180+ let a = Assets::new();
181+ let b = Assets::new();
182+ assert_eq!(a.hash(), b.hash());
183+ assert_eq!(a.hash().len(), 12);
184+ let bundle = a.get(CSS_BUNDLE_PATH).unwrap();
185+ let text = std::str::from_utf8(&bundle).unwrap();
186+ assert!(text.contains("@font-face"));
187+ assert!(text.contains("--text-display"));
188+ }
189+
190+ #[test]
191+ fn content_types() {
192+ assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8");
193+ assert_eq!(
194+ content_type("js/theme.js"),
195+ "text/javascript; charset=utf-8"
196+ );
197+ assert_eq!(content_type("fonts/x.woff2"), "font/woff2");
198+ assert_eq!(content_type("icons/favicon.svg"), "image/svg+xml");
199+ }
200+}
A src/server/dispatch.rs View
@@ -0,0 +1,141 @@
1+//! The catch-all route: map a URL path onto a repository and an action.
2+//!
3+//! Repository names may contain `/`, so the longest prefix of the path that
4+//! names a repository wins; the next segment is the action. Ferrenium's own
5+//! endpoints live under `/-/`, which never names a repository.
6+
7+use std::sync::Arc;
8+
9+use axum::extract::{Query, Request, State};
10+use axum::http::{StatusCode, header};
11+use axum::response::{IntoResponse, Redirect, Response};
12+
13+use crate::handlers::error;
14+use crate::handlers::repo::{PageQuery, RepoRequest, handle};
15+use crate::index::{Repo, RepoIndex};
16+use crate::render::{encode_path, percent_decode};
17+use crate::state::AppState;
18+
19+pub enum Match<'a> {
20+ Repo {
21+ repo: Arc<Repo>,
22+ rest: &'a [Vec<u8>],
23+ },
24+ /// `/{repo}.git` → `/{repo}`.
25+ DotGit {
26+ name: String,
27+ },
28+ None,
29+}
30+
31+/// Split a raw URL path into percent-decoded segments, dropping empty ones.
32+/// `None` for `.`/`..` segments, which are never valid.
33+pub fn segments(path: &str) -> Option<Vec<Vec<u8>>> {
34+ let segments: Vec<Vec<u8>> = path
35+ .split('/')
36+ .filter(|s| !s.is_empty())
37+ .map(percent_decode)
38+ .collect();
39+ let bad = segments
40+ .iter()
41+ .any(|s| s == b"." || s == b".." || s.contains(&b'/') || s.contains(&0));
42+ (!bad).then_some(segments)
43+}
44+
45+/// Find the repository named by the longest prefix of `segments`.
46+pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> {
47+ if segments.first().is_some_and(|s| s == b"-") {
48+ return Match::None;
49+ }
50+ for n in (1..=segments.len()).rev() {
51+ let parts: Option<Vec<&str>> = segments[..n]
52+ .iter()
53+ .map(|s| std::str::from_utf8(s).ok())
54+ .collect();
55+ let Some(parts) = parts else {
56+ continue;
57+ };
58+ let name = parts.join("/");
59+ if let Some(repo) = index.repos.get(&name) {
60+ return Match::Repo {
61+ repo: repo.clone(),
62+ rest: &segments[n..],
63+ };
64+ }
65+ if let Some(stripped) = name.strip_suffix(".git")
66+ && index.repos.contains_key(stripped)
67+ {
68+ // `/{repo}.git/…` is the HTTPS clone endpoint (not served yet).
69+ return if n == segments.len() {
70+ Match::DotGit {
71+ name: stripped.to_string(),
72+ }
73+ } else {
74+ Match::None
75+ };
76+ }
77+ }
78+ Match::None
79+}
80+
81+pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response {
82+ let config = state.config.load_full();
83+ let index = state.index.load();
84+ let Some(segments) = segments(req.uri().path()) else {
85+ return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
86+ };
87+ let (repo, rest) = match find_repo(&index, &segments) {
88+ Match::Repo { repo, rest } => (repo, rest.to_vec()),
89+ Match::DotGit { name } => {
90+ let url = format!("{}{}", config.server.base_path, encode_path(&name));
91+ return Redirect::permanent(&url).into_response();
92+ }
93+ Match::None => return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"),
94+ };
95+ let query = Query::<PageQuery>::try_from_uri(req.uri())
96+ .map(|q| q.0)
97+ .unwrap_or_default();
98+ let request = RepoRequest {
99+ repo,
100+ segments: rest,
101+ query,
102+ if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(),
103+ config,
104+ };
105+ match state.blocking(move |state| handle(state, request)).await {
106+ Ok(response) => response,
107+ Err(e) => {
108+ tracing::error!("request failed: {e:#}");
109+ error::page(&state, StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL ERROR")
110+ }
111+ }
112+}
113+
114+#[cfg(test)]
115+mod tests {
116+ use super::*;
117+ use crate::index::RepoIndex;
118+
119+ #[test]
120+ fn segment_parsing() {
121+ assert_eq!(
122+ segments("/a//b%2Fc/").map(|s| s.len()),
123+ None,
124+ "encoded slash is rejected"
125+ );
126+ assert_eq!(
127+ segments("/a//b/%C3%BC/").unwrap(),
128+ vec![b"a".to_vec(), b"b".to_vec(), "ü".as_bytes().to_vec()]
129+ );
130+ assert!(segments("/a/../b").is_none());
131+ assert!(segments("/a/%2e%2e/b").is_none());
132+ assert!(segments("/").unwrap().is_empty());
133+ }
134+
135+ #[test]
136+ fn no_repos_no_match() {
137+ let index = RepoIndex::default();
138+ let segs = segments("/-/x").unwrap();
139+ assert!(matches!(find_repo(&index, &segs), Match::None));
140+ }
141+}
A src/server/headers.rs View
@@ -0,0 +1,19 @@
1+//! Security headers applied to every response (unless a handler set its own).
2+
3+use axum::http::{HeaderName, HeaderValue, header};
4+use tower_http::set_header::SetResponseHeaderLayer;
5+
6+pub const PAGE_CSP: &str = "default-src 'self'; img-src 'self' https: data:; script-src 'self'; \
7+ object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
8+
9+pub fn layers() -> [SetResponseHeaderLayer<HeaderValue>; 4] {
10+ let set = |name: HeaderName, value: &'static str| {
11+ SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value))
12+ };
13+ [
14+ set(header::CONTENT_SECURITY_POLICY, PAGE_CSP),
15+ set(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
16+ set(header::REFERRER_POLICY, "same-origin"),
17+ set(header::X_FRAME_OPTIONS, "DENY"),
18+ ]
19+}
A src/server/mod.rs View
@@ -0,0 +1,297 @@
1+//! HTTP server: router, middleware, listeners, signals and shutdown.
2+
3+pub mod assets;
4+pub mod dispatch;
5+pub mod headers;
6+
7+use std::future::{Future, IntoFuture};
8+use std::sync::Arc;
9+use std::time::Duration;
10+
11+use anyhow::Context;
12+use axum::Router;
13+use axum::extract::{Request, State};
14+use axum::http::{HeaderValue, StatusCode, Uri, header};
15+use axum::middleware::{self, Next};
16+use axum::response::{IntoResponse, Redirect, Response};
17+use axum::routing::get;
18+use sd_notify::NotifyState;
19+use tokio::signal::unix::{SignalKind, signal};
20+use tokio::sync::watch;
21+use tower::{Layer, ServiceBuilder};
22+use tower_http::CompressionLevel;
23+use tower_http::catch_panic::CatchPanicLayer;
24+use tower_http::compression::predicate::{NotForContentType, Predicate};
25+use tower_http::compression::{CompressionLayer, DefaultPredicate};
26+use tower_http::timeout::TimeoutLayer;
27+use tower_http::trace::{DefaultOnResponse, TraceLayer};
28+use tracing::Level;
29+
30+use crate::config::Listen;
31+use crate::state::AppState;
32+use crate::{handlers, index};
33+
34+/// How long in-flight requests may run after SIGTERM before we exit anyway.
35+const DRAIN_DEADLINE: Duration = Duration::from_secs(10);
36+
37+/// Build the full application (routes + middleware) for the current config.
38+pub fn app(state: Arc<AppState>) -> Router {
39+ let config = state.config.load();
40+
41+ let routes = Router::new()
42+ .route("/", get(handlers::index::index))
43+ .route("/-/health", get(health))
44+ .route("/-/site.css", get(assets::site_css))
45+ .route("/-/static/{hash}/{*path}", get(assets::serve))
46+ .route("/{*path}", get(dispatch::dispatch))
47+ .fallback(handlers::error::not_found)
48+ .with_state(state.clone());
49+
50+ // The prefix must be stripped *before* routing, so wrap the whole router
51+ // (Router::layer would run after route matching).
52+ let routes = match config.server.base_path.as_str() {
53+ "/" => routes,
54+ base => Router::new().fallback_service(
55+ middleware::from_fn_with_state(
56+ Arc::<str>::from(base.trim_end_matches('/')),
57+ strip_base_path,
58+ )
59+ .layer(routes),
60+ ),
61+ };
62+
63+ let trusted_proxy = config.server.trusted_proxy;
64+ let compression = CompressionLayer::new()
65+ .no_deflate()
66+ .no_zstd()
67+ .quality(CompressionLevel::Precise(4));
68+ // Always installed (so the body type is fixed); disabling turns off every
69+ // encoding instead.
70+ let compression = if config.server.compression {
71+ compression
72+ } else {
73+ compression.no_gzip().no_br()
74+ }
75+ // woff2 is already compressed.
76+ .compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/")));
77+
78+ let [csp, nosniff, referrer, frame] = headers::layers();
79+ routes.layer(
80+ ServiceBuilder::new()
81+ .layer(
82+ TraceLayer::new_for_http()
83+ .make_span_with(move |req: &Request| {
84+ let client = trusted_proxy
85+ .then(|| forwarded_for(req))
86+ .flatten()
87+ .unwrap_or("-");
88+ tracing::info_span!(
89+ "request",
90+ method = %req.method(),
91+ uri = %req.uri(),
92+ client = %client,
93+ )
94+ })
95+ .on_request(())
96+ .on_response(DefaultOnResponse::new().level(Level::INFO)),
97+ )
98+ .layer(CatchPanicLayer::new())
99+ .layer(TimeoutLayer::with_status_code(
100+ StatusCode::SERVICE_UNAVAILABLE,
101+ Duration::from_secs(config.limits.request_timeout_secs),
102+ ))
103+ .layer(compression)
104+ .layer(csp)
105+ .layer(nosniff)
106+ .layer(referrer)
107+ .layer(frame),
108+ )
109+}
110+
111+/// First address in `X-Forwarded-For` (the original client, as seen by the
112+/// trusted proxy).
113+fn forwarded_for(req: &Request) -> Option<&str> {
114+ let value = req.headers().get("x-forwarded-for")?.to_str().ok()?;
115+ value
116+ .split(',')
117+ .next()
118+ .map(str::trim)
119+ .filter(|s| !s.is_empty())
120+}
121+
122+/// Serve under `server.base_path`: strip the prefix before routing, redirect
123+/// the bare prefix to its slash form, and 404 everything outside it.
124+async fn strip_base_path(State(base): State<Arc<str>>, mut req: Request, next: Next) -> Response {
125+ let path = req.uri().path();
126+ if path == &*base {
127+ return Redirect::permanent(&format!("{base}/")).into_response();
128+ }
129+ let Some(rest) = path.strip_prefix(&*base).filter(|r| r.starts_with('/')) else {
130+ return StatusCode::NOT_FOUND.into_response();
131+ };
132+ let new_uri = match req.uri().query() {
133+ Some(q) => format!("{rest}?{q}"),
134+ None => rest.to_string(),
135+ };
136+ match new_uri.parse::<Uri>() {
137+ Ok(uri) => {
138+ *req.uri_mut() = uri;
139+ next.run(req).await
140+ }
141+ Err(_) => StatusCode::BAD_REQUEST.into_response(),
142+ }
143+}
144+
145+async fn health() -> impl IntoResponse {
146+ (
147+ [(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))],
148+ "ok\n",
149+ )
150+}
151+
152+/// Run the server until SIGTERM/SIGINT. SIGHUP reloads the config.
153+pub async fn run(state: Arc<AppState>) -> anyhow::Result<()> {
154+ let listen = state.config.load().server.listen.clone();
155+ let (shutdown_tx, shutdown_rx) = watch::channel(false);
156+ tokio::spawn(handle_signals(state.clone(), shutdown_tx)?);
157+
158+ // The index is complete before we report READY.
159+ let st = state.clone();
160+ let stats = tokio::task::spawn_blocking(move || {
161+ let config = st.config.load_full();
162+ st.index.full_scan(&config)
163+ })
164+ .await
165+ .context("initial scan failed")?;
166+ let mut seen = Default::default();
167+ index::watch::log_scan(&stats, &state.index.load(), &mut seen, true);
168+ tokio::spawn(index::watch::run(state.clone(), seen));
169+ // Parse the embedded grammars now rather than on the first blob view.
170+ tokio::task::spawn_blocking(|| {
171+ let start = std::time::Instant::now();
172+ crate::render::highlight::syntaxes();
173+ tracing::debug!("syntaxes loaded in {:?}", start.elapsed());
174+ });
175+
176+ let app = app(state.clone());
177+
178+ match &listen {
179+ Listen::Tcp(addr) => {
180+ let listener = tokio::net::TcpListener::bind(addr)
181+ .await
182+ .with_context(|| format!("cannot listen on {addr}"))?;
183+ serve(listener, app, &listen, shutdown_rx).await
184+ }
185+ Listen::Unix(path) => {
186+ let listener = bind_unix(path)?;
187+ let result = serve(listener, app, &listen, shutdown_rx).await;
188+ let _ = std::fs::remove_file(path);
189+ result
190+ }
191+ }
192+}
193+
194+async fn serve<L>(
195+ listener: L,
196+ app: Router,
197+ listen: &Listen,
198+ mut shutdown_rx: watch::Receiver<bool>,
199+) -> anyhow::Result<()>
200+where
201+ L: axum::serve::Listener,
202+ L::Addr: std::fmt::Debug,
203+{
204+ tracing::info!("listening on {listen}");
205+ notify(&[NotifyState::Ready, NotifyState::Status("serving")]);
206+
207+ let mut drain_rx = shutdown_rx.clone();
208+ let server = axum::serve(listener, app)
209+ .with_graceful_shutdown(async move {
210+ let _ = shutdown_rx.wait_for(|stop| *stop).await;
211+ })
212+ .into_future();
213+
214+ tokio::select! {
215+ result = server => result.context("server error")?,
216+ _ = async {
217+ let _ = drain_rx.wait_for(|stop| *stop).await;
218+ tokio::time::sleep(DRAIN_DEADLINE).await;
219+ } => {
220+ tracing::warn!(
221+ "connections still open after {}s; exiting anyway",
222+ DRAIN_DEADLINE.as_secs()
223+ );
224+ }
225+ }
226+ tracing::info!("stopped");
227+ Ok(())
228+}
229+
230+/// Bind a unix socket, replacing a stale socket file from a previous run.
231+fn bind_unix(path: &std::path::Path) -> anyhow::Result<tokio::net::UnixListener> {
232+ use std::os::unix::fs::{FileTypeExt, PermissionsExt};
233+
234+ if let Ok(meta) = std::fs::symlink_metadata(path) {
235+ anyhow::ensure!(
236+ meta.file_type().is_socket(),
237+ "{} exists and is not a socket",
238+ path.display()
239+ );
240+ std::fs::remove_file(path)
241+ .with_context(|| format!("cannot remove stale socket {}", path.display()))?;
242+ }
243+ let listener = tokio::net::UnixListener::bind(path)
244+ .with_context(|| format!("cannot listen on unix:{}", path.display()))?;
245+ // Same reachability as a loopback TCP port: any local user (i.e. the
246+ // reverse proxy) may connect. All content served is public anyway.
247+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))
248+ .with_context(|| format!("cannot set permissions on {}", path.display()))?;
249+ Ok(listener)
250+}
251+
252+/// Install signal handlers (synchronously, so a failure aborts startup) and
253+/// return the task that services them.
254+fn handle_signals(
255+ state: Arc<AppState>,
256+ shutdown_tx: watch::Sender<bool>,
257+) -> anyhow::Result<impl Future<Output = ()> + Send + 'static> {
258+ let mut term = signal(SignalKind::terminate()).context("cannot install SIGTERM handler")?;
259+ let mut int = signal(SignalKind::interrupt()).context("cannot install SIGINT handler")?;
260+ let mut hup = signal(SignalKind::hangup()).context("cannot install SIGHUP handler")?;
261+
262+ Ok(async move {
263+ loop {
264+ tokio::select! {
265+ _ = term.recv() => { tracing::info!("SIGTERM received; draining"); break; }
266+ _ = int.recv() => { tracing::info!("SIGINT received; draining"); break; }
267+ _ = hup.recv() => reload(&state),
268+ }
269+ }
270+ notify(&[NotifyState::Stopping]);
271+ let _ = shutdown_tx.send(true);
272+ })
273+}
274+
275+fn reload(state: &AppState) {
276+ tracing::info!("SIGHUP received; reloading configuration");
277+ let mut msg = vec![NotifyState::Reloading];
278+ if let Ok(now) = NotifyState::monotonic_usec_now() {
279+ msg.push(now);
280+ }
281+ notify(&msg);
282+ match state.reload() {
283+ Ok(config) => {
284+ crate::logging::apply(&config);
285+ tracing::info!("configuration reloaded");
286+ state.rescan.notify_one();
287+ }
288+ Err(e) => tracing::error!("reload failed, keeping current configuration: {e}"),
289+ }
290+ notify(&[NotifyState::Ready]);
291+}
292+
293+fn notify(states: &[NotifyState]) {
294+ if let Err(e) = sd_notify::notify(states) {
295+ tracing::debug!("sd_notify failed: {e}");
296+ }
297+}
A src/state.rs View
@@ -0,0 +1,109 @@
1+//! Process-wide shared state.
2+
3+use std::path::PathBuf;
4+use std::sync::Arc;
5+use std::sync::atomic::{AtomicU64, Ordering};
6+
7+use arc_swap::ArcSwap;
8+use tokio::sync::Semaphore;
9+
10+use crate::cache::Cache;
11+use crate::config::{Config, ConfigError, Listen};
12+use crate::index::Indexer;
13+use crate::server::assets::Assets;
14+
15+pub struct AppState {
16+ /// Current config; swapped atomically on reload.
17+ pub config: ArcSwap<Config>,
18+ /// Bumped on every successful reload; part of page ETags.
19+ pub config_generation: AtomicU64,
20+ pub assets: Assets,
21+ pub cache: Cache,
22+ /// Bounds concurrent git work (`limits.git_parallel`).
23+ pub git_permits: Semaphore,
24+ pub index: Indexer,
25+ /// Wakes the index watcher for an immediate full rescan.
26+ pub rescan: tokio::sync::Notify,
27+ /// Command-line overrides re-applied after every reload.
28+ pub overrides: Overrides,
29+}
30+
31+#[derive(Debug, Clone, Default)]
32+pub struct Overrides {
33+ pub config_path: Option<PathBuf>,
34+ pub listen: Option<Listen>,
35+}
36+
37+impl Overrides {
38+ pub fn load(&self) -> Result<Config, ConfigError> {
39+ let mut config = Config::load(self.config_path.as_deref())?;
40+ if let Some(listen) = &self.listen {
41+ config.server.listen = listen.clone();
42+ }
43+ Ok(config)
44+ }
45+}
46+
47+impl AppState {
48+ pub fn new(config: Config, overrides: Overrides) -> Arc<Self> {
49+ Arc::new(AppState {
50+ cache: Cache::new(config.cache.size_mb),
51+ git_permits: Semaphore::new(config.git_parallel()),
52+ config_generation: AtomicU64::new(0),
53+ config: ArcSwap::from_pointee(config),
54+ assets: Assets::new(),
55+ index: Indexer::new(),
56+ rescan: tokio::sync::Notify::new(),
57+ overrides,
58+ })
59+ }
60+
61+ /// Re-read the config file and swap it in. On error the running config
62+ /// stays in place. Returns the new config.
63+ pub fn reload(&self) -> Result<Arc<Config>, ConfigError> {
64+ let mut new = self.overrides.load()?;
65+ let old = self.config.load();
66+ // Settings baked into the listener and middleware stack keep their
67+ // running values so the config always describes the live server.
68+ if new.server.listen != old.server.listen {
69+ tracing::warn!(
70+ "server.listen changed to {}; restart required to take effect",
71+ new.server.listen
72+ );
73+ new.server.listen = old.server.listen.clone();
74+ }
75+ if new.server.base_path != old.server.base_path {
76+ tracing::warn!("server.base_path changed; restart required to take effect");
77+ new.server.base_path = old.server.base_path.clone();
78+ }
79+ if new.server.compression != old.server.compression
80+ || new.limits.request_timeout_secs != old.limits.request_timeout_secs
81+ {
82+ tracing::warn!(
83+ "server.compression / limits.request_timeout_secs changed; restart required"
84+ );
85+ new.server.compression = old.server.compression;
86+ new.limits.request_timeout_secs = old.limits.request_timeout_secs;
87+ }
88+ if new.cache.size_mb != old.cache.size_mb || new.git_parallel() != old.git_parallel() {
89+ tracing::warn!("cache.size_mb / limits.git_parallel changed; restart required");
90+ new.cache.size_mb = old.cache.size_mb;
91+ new.limits.git_parallel = old.limits.git_parallel;
92+ }
93+ let new = Arc::new(new);
94+ self.config.store(new.clone());
95+ self.config_generation.fetch_add(1, Ordering::Relaxed);
96+ Ok(new)
97+ }
98+
99+ /// Run blocking git work on the blocking pool, at most
100+ /// `limits.git_parallel` jobs at a time.
101+ pub async fn blocking<T: Send + 'static>(
102+ self: &Arc<Self>,
103+ work: impl FnOnce(&AppState) -> T + Send + 'static,
104+ ) -> anyhow::Result<T> {
105+ let _permit = self.git_permits.acquire().await?;
106+ let state = self.clone();
107+ Ok(tokio::task::spawn_blocking(move || work(&state)).await?)
108+ }
109+}
A templates/base.html View
@@ -0,0 +1,41 @@
1+<!doctype html>
2+<html lang="en" data-default-theme="{{ layout.theme }}"{% if layout.theme != "auto" %} data-theme="{{ layout.theme }}"{% endif %}>
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<meta name="color-scheme" content="dark light">
7+<title>{{ layout.page_title }}</title>
8+<link rel="preload" href="{{ layout.static_base }}fonts/space-grotesk-latin-wght.woff2" as="font" type="font/woff2" crossorigin>
9+<link rel="preload" href="{{ layout.static_base }}fonts/space-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin>
10+<link rel="stylesheet" href="{{ layout.static_base }}css/app.css">
11+{%- if let Some(css) = layout.site_css %}
12+<link rel="stylesheet" href="{{ css }}">
13+{%- endif %}
14+<link rel="icon" href="{{ layout.static_base }}icons/favicon.svg" type="image/svg+xml">
15+<script src="{{ layout.static_base }}js/theme.js"></script>
16+<script src="{{ layout.static_base }}js/app.js" defer></script>
17+{%- block head %}{% endblock %}
18+</head>
19+<body>
20+<header class="topbar shell">
21+ <a class="topbar-home label" href="{{ layout.base }}">{{ layout.site_title }}</a>
22+ <div class="segmented theme-switch" role="group" aria-label="Color theme">
23+ <button type="button" data-theme-choice="auto" aria-pressed="false">Auto</button>
24+ <button type="button" data-theme-choice="light" aria-pressed="false">Light</button>
25+ <button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
26+ </div>
27+</header>
28+{%- if let Some(header) = layout.header %}
29+<div class="site-header shell">{{ header|safe }}</div>
30+{%- endif %}
31+<main class="shell">
32+{% block content %}{% endblock %}
33+</main>
34+<footer class="footer shell">
35+ {%- if let Some(footer) = layout.footer %}
36+ <div class="site-footer">{{ footer|safe }}</div>
37+ {%- endif %}
38+ <span class="label dim">Ferrenium {{ layout.version }}</span>
39+</footer>
40+</body>
41+</html>
A templates/blob.html View
@@ -0,0 +1,42 @@
1+{% extends "base.html" %}
2+{% block content %}
3+{% include "repo_nav.html" %}
4+{% include "crumbs.html" %}
5+<div class="blob-bar">
6+ <p class="label blob-facts">{% for fact in facts %}{% if !loop.first %} · {% endif %}{{ fact }}{% endfor %}</p>
7+ <div class="blob-actions">
8+ {%- if let Some(toggle) = toggle %}
9+ <nav class="segmented segmented-sm" aria-label="View">
10+ <a href="{{ toggle.preview_url }}"{% if !toggle.source %} aria-current="true"{% endif %}>Preview</a>
11+ <a href="{{ toggle.source_url }}"{% if toggle.source %} aria-current="true"{% endif %}>Source</a>
12+ </nav>
13+ {%- endif %}
14+ <a class="btn-ghost" href="{{ raw_url }}">Raw</a>
15+ <a class="btn-ghost" href="{{ download_url }}">Download</a>
16+ </div>
17+</div>
18+{%- match body %}
19+{%- when Body::Code with { lines } %}
20+<div class="code-wrap">
21+<table class="code" data-lines>
22+<tbody>
23+{%- for line in lines.iter() %}
24+<tr id="L{{ loop.index }}"><td class="ln"><a href="#L{{ loop.index }}">{{ loop.index }}</a></td><td class="lc">{{ line|safe }}</td></tr>
25+{%- endfor %}
26+</tbody>
27+</table>
28+</div>
29+{%- when Body::Markdown with { html } %}
30+<article class="markdown blob-markdown">{{ html|safe }}</article>
31+{%- when Body::Image with { src } %}
32+<figure class="image-view dot-grid"><img src="{{ src }}" alt="{{ here.as_deref().unwrap_or_default() }}"></figure>
33+{%- when Body::Symlink with { target, url } %}
34+<p class="blob-note"><span class="label">Symlink to</span> {% if let Some(url) = url %}<a class="mono" href="{{ url }}">{{ target }}</a>{% else %}<span class="mono">{{ target }}</span>{% endif %}</p>
35+{%- when Body::Binary %}
36+<p class="status blob-note">[Binary file]</p>
37+{%- when Body::TooLarge %}
38+<p class="status blob-note">[Too large to display] <a href="{{ raw_url }}">View raw</a></p>
39+{%- when Body::Empty %}
40+<p class="status blob-note">[Empty file]</p>
41+{%- endmatch %}
42+{% endblock %}
A templates/crumbs.html View
@@ -0,0 +1,4 @@
1+<h1 class="crumbs">
2+ {%- for crumb in crumbs %}<a href="{{ crumb.url }}">{{ crumb.name }}</a><span class="crumb-sep" aria-hidden="true">/</span>{% endfor -%}
3+ {%- if let Some(here) = here %}<span class="crumb-here">{{ here }}</span>{% endif -%}
4+</h1>
A templates/error.html View
@@ -0,0 +1,8 @@
1+{% extends "base.html" %}
2+{% block content %}
3+<section class="error-page">
4+ <p class="doto display-xl" aria-hidden="true">{{ code }}</p>
5+ <h1 class="status status-error">[ERROR: {{ message }}]</h1>
6+ <a class="btn-ghost" href="{{ layout.base }}">&larr; Index</a>
7+</section>
8+{% endblock %}
A templates/index.html View
@@ -0,0 +1,56 @@
1+{% extends "base.html" %}
2+{% block content %}
3+<section class="hero">
4+ <div class="hero-main">
5+ <h1 class="doto display-lg">{{ layout.site_title }}</h1>
6+ {%- if !description.is_empty() %}
7+ <p class="hero-desc">{{ description }}</p>
8+ {%- endif %}
9+ </div>
10+ <p class="hero-meta label">{{ total }} {% if total == 1 %}Repository{% else %}Repositories{% endif %}</p>
11+</section>
12+{%- if total == 0 %}
13+<section class="empty">
14+ <p class="empty-title">No repositories</p>
15+ <p class="empty-desc">Push a bare repository under the scan path and it will appear here.</p>
16+</section>
17+{%- else %}
18+<form class="index-tools" method="get" action="{{ layout.base }}" role="search">
19+ <label class="field">
20+ <span class="label">Filter</span>
21+ <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="/" aria-keyshortcuts="/" data-filter>
22+ </label>
23+ {%- if by_activity %}
24+ <input type="hidden" name="sort" value="activity">
25+ {%- endif %}
26+ <nav class="sort" aria-label="Sort by">
27+ <a class="label{% if !by_activity %} active{% endif %}" href="{{ sort_name_url }}"{% if !by_activity %} aria-current="true"{% endif %}>Name</a>
28+ <a class="label{% if by_activity %} active{% endif %}" href="{{ sort_activity_url }}"{% if by_activity %} aria-current="true"{% endif %}>Activity</a>
29+ </nav>
30+</form>
31+{%- for section in sections %}
32+<section class="repo-section" data-section>
33+ {%- if let Some(name) = section.name %}
34+ <h2 class="label section-label">[ {{ name }} ]</h2>
35+ {%- endif %}
36+ <ul class="repo-list">
37+ {%- for repo in section.rows %}
38+ <li class="repo-row" data-search="{{ repo.search }}">
39+ <a class="repo-link" href="{{ repo.url }}">
40+ <span class="repo-name">{{ repo.name }}</span>
41+ {%- if let Some(desc) = repo.description %}
42+ <span class="repo-desc">{{ desc }}</span>
43+ {%- endif %}
44+ </a>
45+ <span class="repo-meta label">
46+ {%- if repo.fresh %}<span class="dot" title="Updated in the last 24 hours"></span>{% endif -%}
47+ {%- if let Some(age) = repo.age %}<time datetime="{{ repo.iso.as_deref().unwrap_or_default() }}" title="{{ repo.iso.as_deref().unwrap_or_default() }}">{{ age }}</time>{% else %}Empty{% endif -%}
48+ </span>
49+ </li>
50+ {%- endfor %}
51+ </ul>
52+</section>
53+{%- endfor %}
54+<p class="status no-matches" data-filter-empty{% if shown > 0 %} hidden{% endif %}>[No matches]</p>
55+{%- endif %}
56+{% endblock %}
A templates/macros.html View
@@ -0,0 +1,24 @@
1+{% macro tree_list(rows, static_base) %}
2+<ul class="tree">
3+ {%- for row in rows %}
4+ <li class="tree-row">
5+ {%- if let Some(url) = row.url %}
6+ <a class="tree-name" href="{{ url }}">
7+ {%- else %}
8+ <span class="tree-name">
9+ {%- endif %}
10+ <svg class="icon icon-{{ row.icon }}" aria-hidden="true"><use href="{{ static_base }}icons/sprite.svg#{{ row.icon }}"></use></svg>
11+ <span>{{ row.name }}</span>
12+ {%- if row.url.is_some() %}</a>{% else %}</span>{% endif %}
13+ <span class="tree-meta">{{ row.meta }}</span>
14+ </li>
15+ {%- endfor %}
16+</ul>
17+{% endmacro %}
18+
19+{% macro readme(readme) %}
20+<article class="readme">
21+ <a class="label readme-name" href="{{ readme.url }}">{{ readme.name }}</a>
22+ <div class="markdown">{{ readme.html|safe }}</div>
23+</article>
24+{% endmacro %}
A templates/ref_table.html View
@@ -0,0 +1,16 @@
1+<div class="table-wrap">
2+<table class="data-table">
3+ <thead>
4+ <tr><th class="label">Name</th><th class="label">Last commit</th><th class="label num">Updated</th></tr>
5+ </thead>
6+ <tbody>
7+ {%- for row in rows %}
8+ <tr>
9+ <td class="ref-name"><a href="{{ row.url }}">{{ row.name }}</a>{% if row.is_default %} <span class="chip">Default</span>{% endif %}</td>
10+ <td class="ref-subject">{{ row.subject }}</td>
11+ <td class="num"><time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></td>
12+ </tr>
13+ {%- endfor %}
14+ </tbody>
15+</table>
16+</div>
A templates/refs.html View
@@ -0,0 +1,23 @@
1+{% extends "base.html" %}
2+{% block content %}
3+{% include "repo_nav.html" %}
4+{% include "crumbs.html" %}
5+<section class="refs-section">
6+ <h2 class="label section-label">Branches</h2>
7+ {%- if branches.is_empty() %}
8+ <p class="status">[No branches]</p>
9+ {%- else %}
10+ {%- let rows = branches %}
11+ {% include "ref_table.html" %}
12+ {%- endif %}
13+</section>
14+<section class="refs-section">
15+ <h2 class="label section-label">Tags</h2>
16+ {%- if tags.is_empty() %}
17+ <p class="status">[No tags]</p>
18+ {%- else %}
19+ {%- let rows = tags %}
20+ {% include "ref_table.html" %}
21+ {%- endif %}
22+</section>
23+{% endblock %}
A templates/repo_nav.html View
@@ -0,0 +1,36 @@
1+<div class="repo-bar">
2+ <nav class="bracket-nav" aria-label="Repository">
3+ <a class="label{% if nav.active == "summary" %} active{% endif %}" href="{{ nav.url }}"{% if nav.active == "summary" %} aria-current="page"{% endif %}>Summary</a>
4+ <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a>
5+ <a class="label{% if nav.active == "refs" %} active{% endif %}" href="{{ nav.refs_url }}"{% if nav.active == "refs" %} aria-current="page"{% endif %}>Refs</a>
6+ </nav>
7+ {%- if let Some(picker) = nav.picker %}
8+ <details class="ref-picker">
9+ <summary>
10+ <svg class="icon" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#git-branch"></use></svg>
11+ <span class="label dim">{{ picker.kind }}</span>
12+ <span class="ref-current">{{ picker.current }}</span>
13+ <svg class="icon chevron" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#chevron-down"></use></svg>
14+ </summary>
15+ <div class="ref-menu">
16+ {%- if !picker.branches.is_empty() %}
17+ <p class="label ref-group">Branches</p>
18+ <ul>
19+ {%- for item in picker.branches %}
20+ <li><a href="{{ item.url }}"{% if item.active %} class="active" aria-current="true"{% endif %}>{{ item.name }}</a></li>
21+ {%- endfor %}
22+ </ul>
23+ {%- endif %}
24+ {%- if !picker.tags.is_empty() %}
25+ <p class="label ref-group">Tags</p>
26+ <ul>
27+ {%- for item in picker.tags %}
28+ <li><a href="{{ item.url }}"{% if item.active %} class="active" aria-current="true"{% endif %}>{{ item.name }}</a></li>
29+ {%- endfor %}
30+ </ul>
31+ {%- endif %}
32+ <a class="label ref-all" href="{{ nav.refs_url }}">{% if picker.more_tags %}All refs{% else %}Refs{% endif %} &rarr;</a>
33+ </div>
34+ </details>
35+ {%- endif %}
36+</div>
A templates/summary.html View
@@ -0,0 +1,66 @@
1+{% extends "base.html" %}
2+{% import "macros.html" as m %}
3+{% block content %}
4+{% include "repo_nav.html" %}
5+<section class="repo-hero">
6+ <h1 class="display-md repo-title">{{ name }}</h1>
7+ {%- if let Some(description) = description %}
8+ <p class="repo-about">{{ description }}</p>
9+ {%- endif %}
10+</section>
11+<div class="repo-grid">
12+ <div class="repo-main">
13+ {%- if let Some(rows) = rows %}
14+ {% call m::tree_list(rows, layout.static_base) %}{% endcall %}
15+ {%- else %}
16+ <section class="empty">
17+ <p class="empty-title">Empty repository</p>
18+ <p class="empty-desc">Push a branch and it will show up here.</p>
19+ </section>
20+ {%- endif %}
21+ {%- if let Some(readme) = readme %}
22+ {% call m::readme(readme) %}{% endcall %}
23+ {%- endif %}
24+ </div>
25+ <aside class="rail">
26+ <dl class="stats">
27+ {%- for stat in stats %}
28+ <div class="stat">
29+ <dt class="label">{{ stat.label }}</dt>
30+ <dd>{% if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd>
31+ </div>
32+ {%- endfor %}
33+ </dl>
34+ {%- if !clone_urls.is_empty() %}
35+ <section class="rail-block" data-clone>
36+ <div class="rail-head">
37+ <h2 class="label">Clone</h2>
38+ {%- if clone_urls.len() > 1 %}
39+ <div class="segmented segmented-sm clone-switch" role="group" aria-label="Clone protocol">
40+ {%- for url in clone_urls %}
41+ <button type="button" data-clone-choice="{{ loop.index0 }}" aria-pressed="{% if loop.first %}true{% else %}false{% endif %}">{{ url.label }}</button>
42+ {%- endfor %}
43+ </div>
44+ {%- endif %}
45+ </div>
46+ {%- for url in clone_urls %}
47+ <div class="clone-row" data-clone-url="{{ loop.index0 }}">
48+ <input class="clone-input" type="text" readonly value="{{ url.url }}" aria-label="{{ url.label }} clone URL" spellcheck="false">
49+ <button type="button" class="icon-btn" data-copy aria-label="Copy {{ url.label }} clone URL" title="Copy">
50+ <svg class="icon" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#copy"></use></svg>
51+ </button>
52+ </div>
53+ {%- endfor %}
54+ <p class="status copy-status" data-copy-status aria-live="polite"></p>
55+ </section>
56+ {%- endif %}
57+ {%- if let Some(latest) = latest %}
58+ <section class="rail-block">
59+ <h2 class="label">Latest</h2>
60+ <p class="latest-subject">{{ latest.subject }}</p>
61+ <p class="latest-meta">{{ latest.author }} · <time datetime="{{ latest.iso }}" title="{{ latest.iso }}">{{ latest.age }}</time> · <a class="sha" href="{{ latest.url }}">{{ latest.id }}</a></p>
62+ </section>
63+ {%- endif %}
64+ </aside>
65+</div>
66+{% endblock %}
A templates/tree.html View
@@ -0,0 +1,11 @@
1+{% extends "base.html" %}
2+{% import "macros.html" as m %}
3+{% block content %}
4+{% include "repo_nav.html" %}
5+{% include "crumbs.html" %}
6+{% call m::tree_list(rows, layout.static_base) %}{% endcall %}
7+<p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }}</p>
8+{%- if let Some(readme) = readme %}
9+{% call m::readme(readme) %}{% endcall %}
10+{%- endif %}
11+{% endblock %}
A tests/browse.rs View
@@ -0,0 +1,409 @@
1+//! Browsing pages (summary, tree, blob, raw, refs) against real repos.
2+
3+mod common;
4+
5+use axum::body::{Body, to_bytes};
6+use axum::http::{HeaderMap, Request, StatusCode, header};
7+use common::{Rich, bare, rich_repo};
8+use ferrenium::config::Config;
9+use ferrenium::server;
10+use ferrenium::state::{AppState, Overrides};
11+use tower::ServiceExt;
12+
13+struct Site {
14+ _root: tempfile::TempDir,
15+ app: axum::Router,
16+ rich: Rich,
17+}
18+
19+fn site(extra: &str) -> Site {
20+ let root = tempfile::tempdir().unwrap();
21+ let rich = rich_repo(root.path(), "rich.git");
22+ rich_repo(root.path(), "forks/nested.git");
23+ bare(root.path(), "empty.git", &[]);
24+ let toml = format!(
25+ "[repos]\nscan_path = {:?}\n[watch]\nenabled = false\n\
26+ [site]\nclone_urls = [\"https://git.example.com/{{name}}\", \"git@example.com:{{name}}\"]\n\
27+ [ui]\nmax_blob_kb = 2\n{extra}",
28+ root.path().to_str().unwrap()
29+ );
30+ let mut config = Config::parse(&toml).expect("parse");
31+ config.finish().expect("valid");
32+ let state = AppState::new(config, Overrides::default());
33+ let config = state.config.load_full();
34+ state.index.full_scan(&config);
35+ Site {
36+ app: server::app(state),
37+ rich,
38+ _root: root,
39+ }
40+}
41+
42+struct Res {
43+ status: StatusCode,
44+ headers: HeaderMap,
45+ body: String,
46+}
47+
48+impl Res {
49+ fn header(&self, name: header::HeaderName) -> &str {
50+ self.headers
51+ .get(&name)
52+ .map(|v| v.to_str().unwrap())
53+ .unwrap_or_default()
54+ }
55+}
56+
57+async fn fetch(app: &axum::Router, uri: &str, etag: Option<&str>) -> Res {
58+ let mut req = Request::get(uri);
59+ if let Some(etag) = etag {
60+ req = req.header(header::IF_NONE_MATCH, etag);
61+ }
62+ let res = app
63+ .clone()
64+ .oneshot(req.body(Body::empty()).unwrap())
65+ .await
66+ .unwrap();
67+ let status = res.status();
68+ let headers = res.headers().clone();
69+ let body = to_bytes(res.into_body(), usize::MAX).await.unwrap();
70+ Res {
71+ status,
72+ headers,
73+ body: String::from_utf8_lossy(&body).into_owned(),
74+ }
75+}
76+
77+async fn ok(app: &axum::Router, uri: &str) -> Res {
78+ let res = fetch(app, uri, None).await;
79+ assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.body);
80+ assert_csp_clean(&res.body);
81+ res
82+}
83+
84+/// Pages must work under `script-src 'self'` and no inline styles.
85+fn assert_csp_clean(html: &str) {
86+ assert!(!html.contains("<script>"), "inline script");
87+ assert!(!html.contains("<style"), "inline style");
88+ assert!(!html.contains("style=\""), "style attribute");
89+}
90+
91+#[tokio::test]
92+async fn summary_shows_tree_readme_and_rail() {
93+ let s = site("");
94+ let res = ok(&s.app, "/rich").await;
95+ let html = &res.body;
96+ assert!(
97+ html.contains(r#"<h1 class="display-md repo-title">rich</h1>"#),
98+ "{html}"
99+ );
100+ // Root tree: directories first.
101+ let docs = html.find(">docs<").expect("docs row");
102+ let readme = html.find(">README.md<").expect("README row");
103+ assert!(docs < readme);
104+ assert!(html.contains(r#"href="/rich/tree/main/src""#));
105+ assert!(html.contains(r#"href="/rich/blob/main/README.md""#));
106+ // README: rendered, sanitized, relative links rewritten.
107+ assert!(
108+ html.contains(r#"href="/rich/blob/main/docs/guide.md""#),
109+ "{html}"
110+ );
111+ assert!(html.contains(r#"src="/rich/raw/main/img/logo.png""#));
112+ assert!(!html.contains("alert(1)"));
113+ // Rail.
114+ assert!(html.contains("<dd>2</dd>"), "commit count: {html}");
115+ assert!(html.contains("https://git.example.com/rich"));
116+ assert!(html.contains("git@example.com:rich"));
117+ assert!(html.contains(">HTTPS<") && html.contains(">SSH<"));
118+ assert!(html.contains("add everything"));
119+ assert!(html.contains(&format!(r#"href="/rich/tree/{}""#, s.rich.head)));
120+ // Nav + picker.
121+ assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#));
122+ assert!(html.contains(r#"href="/rich/tree/feature/x""#));
123+ assert!(html.contains(r#"href="/rich/tree/v1.1""#));
124+
125+ // Conditional request.
126+ let etag = res.header(header::ETAG).to_string();
127+ assert!(etag.starts_with("W/\""), "{etag}");
128+ assert_eq!(res.header(header::CACHE_CONTROL), "no-cache");
129+ let again = fetch(&s.app, "/rich", Some(&etag)).await;
130+ assert_eq!(again.status, StatusCode::NOT_MODIFIED);
131+ assert!(again.body.is_empty());
132+ assert_eq!(again.header(header::ETAG), etag);
133+}
134+
135+#[tokio::test]
136+async fn empty_repo_summary() {
137+ let s = site("");
138+ let res = ok(&s.app, "/empty").await;
139+ assert!(res.body.contains("Empty repository"));
140+ assert!(res.body.contains("git@example.com:empty"));
141+ let tree = fetch(&s.app, "/empty/tree", None).await;
142+ assert_eq!(tree.status, StatusCode::NOT_FOUND);
143+ assert!(tree.body.contains("[ERROR: REPOSITORY IS EMPTY]"));
144+}
145+
146+#[tokio::test]
147+async fn dispatcher_matches_repos_and_revisions() {
148+ let s = site("");
149+ // Slashy repo name.
150+ ok(&s.app, "/forks/nested").await;
151+ ok(&s.app, "/forks/nested/tree/main/src").await;
152+ // `.git` suffix redirects.
153+ let res = fetch(&s.app, "/forks/nested.git", None).await;
154+ assert_eq!(res.status, StatusCode::PERMANENT_REDIRECT);
155+ assert_eq!(res.header(header::LOCATION), "/forks/nested");
156+ // Slashy branch, tags, full and short ids.
157+ let res = ok(&s.app, "/rich/tree/feature/x").await;
158+ assert!(res.body.contains("only-on-feature.txt"));
159+ let res = ok(&s.app, "/rich/blob/feature/x/only-on-feature.txt").await;
160+ assert!(res.body.contains("feature"));
161+ let res = ok(&s.app, "/rich/tree/v1.1").await;
162+ assert!(!res.body.contains("data.bin"), "v1.1 is the first commit");
163+ ok(&s.app, "/rich/blob/v1.0/data.bin").await;
164+ let full = ok(&s.app, &format!("/rich/tree/{}", s.rich.first)).await;
165+ assert_eq!(full.header(header::CACHE_CONTROL), "public, max-age=86400");
166+ let short = ok(&s.app, &format!("/rich/tree/{}", &s.rich.feature[..8])).await;
167+ assert!(short.body.contains("only-on-feature.txt"));
168+ assert_eq!(short.header(header::CACHE_CONTROL), "no-cache");
169+
170+ for (uri, message) in [
171+ ("/nope", "PAGE NOT FOUND"),
172+ ("/rich/tree/nope", "UNKNOWN REVISION"),
173+ ("/rich/tree/main/nope", "PATH NOT FOUND IN main"),
174+ ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"),
175+ ("/rich/log", "PAGE NOT FOUND"),
176+ ("/rich/tree/main/../x", "PAGE NOT FOUND"),
177+ ("/-/nope", "PAGE NOT FOUND"),
178+ ] {
179+ let res = fetch(&s.app, uri, None).await;
180+ assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
181+ assert!(
182+ res.body.contains(&format!("[ERROR: {message}]")),
183+ "{uri}: {}",
184+ res.body
185+ );
186+ }
187+}
188+
189+#[tokio::test]
190+async fn tree_page() {
191+ let s = site("");
192+ // Submodule: no link, short commit id instead of a size.
193+ let res = ok(&s.app, "/rich/tree/main/vendor").await;
194+ let row = res.body.split("<li class=\"tree-row\">").nth(2).unwrap();
195+ assert!(row.contains(r#"<span class="tree-name">"#), "{row}");
196+ assert!(row.contains(&format!("@ {}", &s.rich.first[..7])), "{row}");
197+
198+ let res = ok(&s.app, "/rich/tree/main/docs").await;
199+ let html = &res.body;
200+ assert!(
201+ html.contains(r#"<a href="/rich/tree/main">rich</a>"#),
202+ "{html}"
203+ );
204+ assert!(html.contains(r#"<span class="crumb-here">docs</span>"#));
205+ assert!(html.contains(r#"href="/rich/tree/main">"#)); // ".." row
206+ assert!(html.contains("2 entries · Tree "));
207+ // Plain-text directory README, escaped.
208+ assert!(
209+ html.contains("Plain docs readme &lt;b&gt;not html&lt;/b&gt;"),
210+ "{html}"
211+ );
212+ // Picker keeps the path.
213+ assert!(html.contains(r#"href="/rich/tree/v1.0/docs""#));
214+ // Unicode, spaces and `#` are percent-encoded.
215+ let res = ok(&s.app, "/rich/tree/main").await;
216+ assert!(
217+ res.body.contains("/rich/tree/main/%C3%BCn%C3%AFcode%20dir"),
218+ "{}",
219+ res.body
220+ );
221+ let res = ok(&s.app, "/rich/tree/main/%C3%BCn%C3%AFcode%20dir").await;
222+ assert!(
223+ res.body
224+ .contains("/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt")
225+ );
226+ // ETag round trip.
227+ let etag = res.header(header::ETAG).to_string();
228+ let again = fetch(
229+ &s.app,
230+ "/rich/tree/main/%C3%BCn%C3%AFcode%20dir",
231+ Some(&etag),
232+ )
233+ .await;
234+ assert_eq!(again.status, StatusCode::NOT_MODIFIED);
235+
236+ // A file in tree view redirects to the blob view, and vice versa.
237+ let res = fetch(&s.app, "/rich/tree/main/src/main.rs", None).await;
238+ assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
239+ assert_eq!(res.header(header::LOCATION), "/rich/blob/main/src/main.rs");
240+ let res = fetch(&s.app, "/rich/blob/main/src", None).await;
241+ assert_eq!(res.header(header::LOCATION), "/rich/tree/main/src");
242+}
243+
244+#[tokio::test]
245+async fn blob_page_variants() {
246+ let s = site("");
247+ // Highlighted code with line anchors.
248+ let res = ok(&s.app, "/rich/blob/main/src/main.rs").await;
249+ let html = &res.body;
250+ assert!(html.contains("4 lines · 45 B · Rust"), "{html}");
251+ assert!(html.contains(r##"<tr id="L2"><td class="ln"><a href="#L2">2</a></td>"##));
252+ assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}");
253+ assert!(html.contains("&lt;hi&gt;"));
254+ assert!(html.contains(r#"href="/rich/raw/main/src/main.rs""#));
255+ assert!(html.contains(r#"href="/rich/raw/main/src/main.rs?download""#));
256+ assert!(html.contains(r#"<span class="crumb-here">main.rs</span>"#));
257+
258+ // Markdown: preview by default, source on request.
259+ let res = ok(&s.app, "/rich/blob/main/docs/guide.md").await;
260+ assert!(
261+ res.body
262+ .contains(r#"<article class="markdown blob-markdown">"#)
263+ );
264+ assert!(
265+ res.body.contains(r#"href="/rich/blob/main/README.md""#),
266+ "{}",
267+ res.body
268+ );
269+ let res = ok(&s.app, "/rich/blob/main/docs/guide.md?view=source").await;
270+ assert!(res.body.contains(r#"id="L1""#));
271+ assert!(res.body.contains(r#"aria-current="true">Source"#));
272+
273+ let res = ok(&s.app, "/rich/blob/main/img/logo.png").await;
274+ assert!(
275+ res.body
276+ .contains(r#"<img src="/rich/raw/main/img/logo.png""#)
277+ );
278+ let res = ok(&s.app, "/rich/blob/main/data.bin").await;
279+ assert!(res.body.contains("[Binary file]"));
280+ let res = ok(&s.app, "/rich/blob/main/big.txt").await;
281+ assert!(
282+ res.body.contains("[Too large to display]"),
283+ "max_blob_kb = 2"
284+ );
285+ let res = ok(&s.app, "/rich/blob/main/link-to-main").await;
286+ assert!(
287+ res.body
288+ .contains(r#"href="/rich/blob/main/src/main.rs">src/main.rs</a>"#)
289+ );
290+ let res = ok(
291+ &s.app,
292+ "/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt",
293+ )
294+ .await;
295+ assert!(res.body.contains("fïle #1.txt"));
296+ // HTML source is shown as text, never rendered.
297+ let res = ok(&s.app, "/rich/blob/main/page.html").await;
298+ assert!(res.body.contains("alert") && !res.body.contains("<script>alert"));
299+}
300+
301+#[tokio::test]
302+async fn raw_is_sandboxed() {
303+ let s = site("");
304+ let res = fetch(&s.app, "/rich/raw/main/page.html", None).await;
305+ assert_eq!(res.status, StatusCode::OK);
306+ assert_eq!(
307+ res.header(header::CONTENT_TYPE),
308+ "text/plain; charset=utf-8"
309+ );
310+ assert_eq!(res.header(header::X_CONTENT_TYPE_OPTIONS), "nosniff");
311+ assert!(
312+ res.header(header::CONTENT_SECURITY_POLICY)
313+ .contains("sandbox")
314+ );
315+ assert_eq!(res.body, "<html><script>alert(1)</script></html>\n");
316+
317+ let res = fetch(&s.app, "/rich/raw/main/img/logo.png", None).await;
318+ assert_eq!(res.header(header::CONTENT_TYPE), "image/png");
319+ let res = fetch(&s.app, "/rich/raw/main/data.bin", None).await;
320+ assert_eq!(res.header(header::CONTENT_TYPE), "application/octet-stream");
321+
322+ let res = fetch(&s.app, "/rich/raw/main/page.html?download", None).await;
323+ assert_eq!(
324+ res.header(header::CONTENT_DISPOSITION),
325+ "attachment; filename=\"page.html\"; filename*=UTF-8''page.html"
326+ );
327+ let res = fetch(
328+ &s.app,
329+ "/rich/raw/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt?download",
330+ None,
331+ )
332+ .await;
333+ assert!(
334+ res.header(header::CONTENT_DISPOSITION)
335+ .contains("filename*=UTF-8''f%C3%AFle%20%231.txt")
336+ );
337+
338+ // Raw by full id is cacheable; ETag revalidates.
339+ let uri = format!("/rich/raw/{}/src/main.rs", s.rich.head);
340+ let res = fetch(&s.app, &uri, None).await;
341+ assert_eq!(res.header(header::CACHE_CONTROL), "public, max-age=86400");
342+ let etag = res.header(header::ETAG).to_string();
343+ assert_eq!(
344+ fetch(&s.app, &uri, Some(&etag)).await.status,
345+ StatusCode::NOT_MODIFIED
346+ );
347+
348+ assert_eq!(
349+ fetch(&s.app, "/rich/raw/main/src", None).await.status,
350+ StatusCode::NOT_FOUND
351+ );
352+}
353+
354+#[tokio::test]
355+async fn refs_page() {
356+ let s = site("");
357+ let res = ok(&s.app, "/rich/refs").await;
358+ let html = &res.body;
359+ let main = html.find(r#"href="/rich/tree/main""#).expect("main");
360+ let feature = html
361+ .find(r#"href="/rich/tree/feature/x""#)
362+ .expect("feature");
363+ assert!(main < feature, "default branch first");
364+ assert!(html.contains(r#"<span class="chip">Default</span>"#));
365+ assert!(html.contains("Release 1.1"), "annotated tag subject");
366+ assert!(html.contains(r#"href="/rich/tree/v1.0""#));
367+ assert!(html.contains(r#"<span class="crumb-here">Refs</span>"#));
368+}
369+
370+#[tokio::test]
371+async fn base_path_prefixes_repo_links() {
372+ let s = site("[server]\nbase_path = \"/git/\"\n");
373+ let res = ok(&s.app, "/git/rich").await;
374+ assert!(res.body.contains(r#"href="/git/rich/tree/main/src""#));
375+ assert!(
376+ res.body
377+ .contains(r#"href="/git/rich/blob/main/docs/guide.md""#)
378+ );
379+ let res = fetch(&s.app, "/git/forks/nested.git", None).await;
380+ assert_eq!(res.header(header::LOCATION), "/git/forks/nested");
381+ ok(&s.app, "/git/rich/blob/main/src/main.rs").await;
382+}
383+
384+#[tokio::test]
385+async fn every_referenced_icon_exists() {
386+ let s = site("");
387+ let res = ok(&s.app, "/rich").await;
388+ let sprite_url = res
389+ .body
390+ .split('"')
391+ .find(|s| s.contains("icons/sprite.svg#"))
392+ .unwrap()
393+ .split('#')
394+ .next()
395+ .unwrap()
396+ .to_string();
397+ let sprite = ok(&s.app, &sprite_url).await.body;
398+ for id in res
399+ .body
400+ .split("sprite.svg#")
401+ .skip(1)
402+ .map(|s| s.split('"').next().unwrap())
403+ {
404+ assert!(
405+ sprite.contains(&format!("id=\"{id}\"")),
406+ "missing icon {id}"
407+ );
408+ }
409+}
A tests/common/mod.rs View
@@ -0,0 +1,153 @@
1+//! Fixture helpers: build real repositories with the git CLI.
2+
3+#![allow(dead_code)]
4+
5+use std::path::{Path, PathBuf};
6+use std::process::Command;
7+
8+/// Run git in `dir` with an isolated environment; panics on failure.
9+pub fn git(dir: &Path, args: &[&str]) -> String {
10+ let home = std::env::temp_dir();
11+ let out = Command::new("git")
12+ .args(args)
13+ .current_dir(dir)
14+ .env("GIT_CONFIG_NOSYSTEM", "1")
15+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
16+ .env("HOME", home)
17+ .env("GIT_AUTHOR_NAME", "Ada")
18+ .env("GIT_AUTHOR_EMAIL", "ada@example.com")
19+ .env("GIT_COMMITTER_NAME", "Ada")
20+ .env("GIT_COMMITTER_EMAIL", "ada@example.com")
21+ .env("GIT_TERMINAL_PROMPT", "0")
22+ .output()
23+ .expect("git runs");
24+ assert!(
25+ out.status.success(),
26+ "git {args:?} failed:\n{}",
27+ String::from_utf8_lossy(&out.stderr)
28+ );
29+ String::from_utf8_lossy(&out.stdout).trim().to_string()
30+}
31+
32+/// A bare repo at `root/rel` (created with `init --bare`).
33+pub fn bare(root: &Path, rel: &str, extra: &[&str]) -> PathBuf {
34+ let path = root.join(rel);
35+ std::fs::create_dir_all(&path).unwrap();
36+ let mut args = vec!["init", "--bare", "-q", "-b", "main"];
37+ args.extend_from_slice(extra);
38+ git(&path, &args);
39+ path
40+}
41+
42+/// A scratch clone of `bare` used to make commits and push them.
43+pub struct Work {
44+ pub dir: tempfile::TempDir,
45+}
46+
47+impl Work {
48+ pub fn clone_of(bare: &Path) -> Work {
49+ let dir = tempfile::tempdir().unwrap();
50+ git(dir.path(), &["clone", "-q", bare.to_str().unwrap(), "."]);
51+ git(dir.path(), &["checkout", "-q", "-B", "main"]);
52+ Work { dir }
53+ }
54+
55+ pub fn path(&self) -> &Path {
56+ self.dir.path()
57+ }
58+
59+ /// Write `file`, commit it and return the new commit id.
60+ pub fn commit(&self, file: &str, content: &str, message: &str) -> String {
61+ let path = self.path().join(file);
62+ if let Some(parent) = path.parent() {
63+ std::fs::create_dir_all(parent).unwrap();
64+ }
65+ std::fs::write(&path, content).unwrap();
66+ git(self.path(), &["add", "-A"]);
67+ git(self.path(), &["commit", "-q", "-m", message]);
68+ git(self.path(), &["rev-parse", "HEAD"])
69+ }
70+
71+ pub fn push(&self, refspec: &str) {
72+ git(self.path(), &["push", "-q", "origin", refspec]);
73+ }
74+}
75+
76+/// Bare repo with one commit on `main` containing `files`.
77+pub fn repo_with(root: &Path, rel: &str, files: &[(&str, &str)]) -> (PathBuf, String) {
78+ let bare = bare(root, rel, &[]);
79+ let work = Work::clone_of(&bare);
80+ let mut head = String::new();
81+ for (name, content) in files {
82+ head = work.commit(name, content, &format!("add {name}"));
83+ }
84+ work.push("main");
85+ (bare, head)
86+}
87+
88+/// Ids of interesting objects in [`rich_repo`].
89+pub struct Rich {
90+ pub bare: PathBuf,
91+ pub head: String,
92+ pub first: String,
93+ pub feature: String,
94+}
95+
96+/// A bare repo exercising the browsing edge cases: nested dirs, a README
97+/// with relative links, a binary and an oversize file, a unicode path, a
98+/// symlink, a submodule entry, a slashy branch and both kinds of tags.
99+pub fn rich_repo(root: &Path, rel: &str) -> Rich {
100+ let bare = bare(root, rel, &[]);
101+ let work = Work::clone_of(&bare);
102+ let w = work.path();
103+ let write = |name: &str, data: &[u8]| {
104+ let path = w.join(name);
105+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
106+ std::fs::write(path, data).unwrap();
107+ };
108+ write(
109+ "src/main.rs",
110+ b"// entry\nfn main() {\n println!(\"<hi>\");\n}\n",
111+ );
112+ let first = work.commit(
113+ "README.md",
114+ "# Rich\n\nSee [the guide](docs/guide.md) and ![logo](img/logo.png).\n\n\
115+ <script>alert(1)</script>\n",
116+ "first commit",
117+ );
118+ write("docs/guide.md", b"# Guide\n\n[Back](../README.md)\n");
119+ write("docs/README", b"Plain docs readme <b>not html</b>\n");
120+ write("page.html", b"<html><script>alert(1)</script></html>\n");
121+ write("img/logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR");
122+ write("data.bin", b"\0\x01\x02binary\0");
123+ write("big.txt", &b"0123456789abcdef\n".repeat(200)); // 3400 bytes
124+ write("ünïcode dir/fïle #1.txt", b"unicode\n");
125+ std::os::unix::fs::symlink("src/main.rs", w.join("link-to-main")).unwrap();
126+ git(w, &["add", "-A"]);
127+ git(
128+ w,
129+ &[
130+ "update-index",
131+ "--add",
132+ "--cacheinfo",
133+ &format!("160000,{first},vendor/lib"),
134+ ],
135+ );
136+ git(w, &["commit", "-q", "-m", "add everything"]);
137+ let head = git(w, &["rev-parse", "HEAD"]);
138+ git(w, &["tag", "v1.0"]);
139+ git(
140+ w,
141+ &["tag", "-a", "v1.1", "-m", "Release 1.1", first.as_str()],
142+ );
143+ git(w, &["checkout", "-q", "-b", "feature/x"]);
144+ let feature = work.commit("only-on-feature.txt", "feature\n", "feature work");
145+ git(w, &["checkout", "-q", "main"]);
146+ git(w, &["push", "-q", "origin", "main", "feature/x", "--tags"]);
147+ Rich {
148+ bare,
149+ head,
150+ first,
151+ feature,
152+ }
153+}
A tests/http.rs View
@@ -0,0 +1,215 @@
1+//! Route-level tests driven through `tower::ServiceExt::oneshot`.
2+
3+use axum::body::{Body, to_bytes};
4+use axum::http::{Request, StatusCode, header};
5+use ferrenium::config::Config;
6+use ferrenium::server;
7+use ferrenium::state::{AppState, Overrides};
8+use tower::ServiceExt;
9+
10+fn app_with(toml: &str) -> (axum::Router, std::sync::Arc<AppState>) {
11+ let mut config = Config::parse(toml).expect("parse");
12+ config.finish().expect("valid");
13+ let state = AppState::new(config, Overrides::default());
14+ (server::app(state.clone()), state)
15+}
16+
17+async fn get(app: &axum::Router, uri: &str) -> (StatusCode, axum::http::HeaderMap, String) {
18+ let res = app
19+ .clone()
20+ .oneshot(Request::get(uri).body(Body::empty()).unwrap())
21+ .await
22+ .unwrap();
23+ let status = res.status();
24+ let headers = res.headers().clone();
25+ let body = to_bytes(res.into_body(), usize::MAX).await.unwrap();
26+ (status, headers, String::from_utf8_lossy(&body).into_owned())
27+}
28+
29+#[tokio::test]
30+async fn index_renders_styled_empty_page() {
31+ let (app, state) =
32+ app_with("[site]\ntitle = \"krolyxon\"\ndescription = \"Personal <repos>\"\n");
33+ let (status, headers, body) = get(&app, "/").await;
34+ assert_eq!(status, StatusCode::OK);
35+ assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8");
36+ assert!(
37+ headers[header::CONTENT_SECURITY_POLICY]
38+ .to_str()
39+ .unwrap()
40+ .contains("script-src 'self'")
41+ );
42+ assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
43+
44+ let static_base = format!("/-/static/{}/", state.assets.hash());
45+ assert!(
46+ body.contains(&format!("{static_base}css/app.css")),
47+ "{body}"
48+ );
49+ assert!(body.contains(&format!("{static_base}js/theme.js")));
50+ assert!(body.contains(r#"data-default-theme="auto""#));
51+ assert!(!body.contains("data-theme=\"")); // auto: OS decides
52+ assert!(body.contains("krolyxon"));
53+ // Escaped by askama.
54+ assert!(body.contains("Personal &#60;repos&#62;") || body.contains("Personal &lt;repos&gt;"));
55+ assert!(body.contains("No repositories"));
56+ // No inline scripts or styles (CSP).
57+ assert!(!body.contains("<script>"));
58+ assert!(!body.contains("<style"));
59+ assert!(!body.contains("style=\""));
60+}
61+
62+#[tokio::test]
63+async fn forced_theme_is_rendered_server_side() {
64+ let (app, _) = app_with("[ui]\ntheme = \"light\"\n");
65+ let (_, _, body) = get(&app, "/").await;
66+ assert!(body.contains(r#"data-default-theme="light" data-theme="light""#));
67+}
68+
69+#[tokio::test]
70+async fn health() {
71+ let (app, _) = app_with("");
72+ let (status, headers, body) = get(&app, "/-/health").await;
73+ assert_eq!(status, StatusCode::OK);
74+ assert_eq!(body, "ok\n");
75+ assert_eq!(headers[header::CACHE_CONTROL], "no-store");
76+}
77+
78+#[tokio::test]
79+async fn static_assets() {
80+ let (app, state) = app_with("");
81+ let hash = state.assets.hash().to_string();
82+
83+ let (status, headers, body) = get(&app, &format!("/-/static/{hash}/css/app.css")).await;
84+ assert_eq!(status, StatusCode::OK);
85+ assert_eq!(headers[header::CONTENT_TYPE], "text/css; charset=utf-8");
86+ let expected = if cfg!(debug_assertions) {
87+ "no-cache"
88+ } else {
89+ "public, max-age=31536000, immutable"
90+ };
91+ assert_eq!(headers[header::CACHE_CONTROL], expected);
92+ assert!(body.contains("--text-display"));
93+ assert!(body.contains("url(../fonts/space-mono-latin-400.woff2)"));
94+
95+ let (status, headers, _) = get(
96+ &app,
97+ &format!("/-/static/{hash}/fonts/space-mono-latin-400.woff2"),
98+ )
99+ .await;
100+ assert_eq!(status, StatusCode::OK);
101+ assert_eq!(headers[header::CONTENT_TYPE], "font/woff2");
102+
103+ // Stale hash: still served, never cached as immutable.
104+ let (status, headers, _) = get(&app, "/-/static/000000000000/js/theme.js").await;
105+ assert_eq!(status, StatusCode::OK);
106+ assert_eq!(headers[header::CACHE_CONTROL], "no-cache");
107+
108+ let (status, _, _) = get(&app, &format!("/-/static/{hash}/nope.css")).await;
109+ assert_eq!(status, StatusCode::NOT_FOUND);
110+ let (status, _, _) = get(&app, &format!("/-/static/{hash}/../Cargo.toml")).await;
111+ assert_eq!(status, StatusCode::NOT_FOUND);
112+}
113+
114+#[tokio::test]
115+async fn every_referenced_asset_exists() {
116+ let (app, _) = app_with("");
117+ let (_, _, page) = get(&app, "/").await;
118+ let mut urls: Vec<String> = page
119+ .split(['"', '\''])
120+ .filter(|s| s.starts_with("/-/static/"))
121+ .map(str::to_string)
122+ .collect();
123+ // Fonts referenced from the stylesheet resolve relative to css/, with
124+ // `..` collapsed the way a browser does before requesting.
125+ let css_url = urls
126+ .iter()
127+ .find(|u| u.ends_with("css/app.css"))
128+ .unwrap()
129+ .clone();
130+ let (_, _, css) = get(&app, &css_url).await;
131+ let static_root = css_url.trim_end_matches("css/app.css");
132+ for part in css.split("url(").skip(1) {
133+ let rel = part.split(')').next().unwrap();
134+ let rel = rel.strip_prefix("../").expect("font urls are ../fonts/…");
135+ urls.push(format!("{static_root}{rel}"));
136+ }
137+ assert!(urls.len() > 10, "{urls:?}");
138+ for url in urls {
139+ let (status, _, _) = get(&app, &url).await;
140+ assert_eq!(status, StatusCode::OK, "{url}");
141+ }
142+}
143+
144+#[tokio::test]
145+async fn not_found_page() {
146+ let (app, _) = app_with("");
147+ let (status, headers, body) = get(&app, "/no/such/thing").await;
148+ assert_eq!(status, StatusCode::NOT_FOUND);
149+ assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8");
150+ assert!(body.contains(">404<"));
151+ assert!(body.contains("[ERROR: PAGE NOT FOUND]"));
152+}
153+
154+#[tokio::test]
155+async fn base_path_prefixes_routes_and_links() {
156+ let (app, state) = app_with("[server]\nbase_path = \"/git\"\n");
157+ let (status, _, body) = get(&app, "/git/").await;
158+ assert_eq!(status, StatusCode::OK);
159+ assert!(body.contains(&format!(
160+ "/git/-/static/{}/css/app.css",
161+ state.assets.hash()
162+ )));
163+ assert!(body.contains(r#"href="/git/""#));
164+
165+ let (status, headers, _) = get(&app, "/git").await;
166+ assert_eq!(status, StatusCode::PERMANENT_REDIRECT);
167+ assert_eq!(headers[header::LOCATION], "/git/");
168+
169+ assert_eq!(get(&app, "/git/-/health").await.0, StatusCode::OK);
170+ assert_eq!(get(&app, "/-/health").await.0, StatusCode::NOT_FOUND);
171+ assert_eq!(get(&app, "/gitx/").await.0, StatusCode::NOT_FOUND);
172+}
173+
174+#[tokio::test]
175+async fn site_css_carries_code_font_and_custom_css() {
176+ let (app, _) = app_with("");
177+ let (_, _, body) = get(&app, "/").await;
178+ assert!(!body.contains("/-/site.css"));
179+
180+ let (app, _) = app_with("[ui]\ncode_font = \"Berkeley Mono\"\n");
181+ let (_, _, body) = get(&app, "/").await;
182+ let href = body
183+ .split('"')
184+ .find(|s| s.starts_with("/-/site.css?v="))
185+ .expect("site.css link");
186+ let (status, _, css) = get(&app, href).await;
187+ assert_eq!(status, StatusCode::OK);
188+ assert!(css.contains("--font-code: \"Berkeley Mono\""));
189+}
190+
191+#[tokio::test]
192+async fn compression_skips_fonts() {
193+ let (app, state) = app_with("");
194+ let hash = state.assets.hash().to_string();
195+ let req = |uri: String| {
196+ Request::get(uri)
197+ .header(header::ACCEPT_ENCODING, "br, gzip")
198+ .body(Body::empty())
199+ .unwrap()
200+ };
201+ let res = app
202+ .clone()
203+ .oneshot(req(format!("/-/static/{hash}/css/app.css")))
204+ .await
205+ .unwrap();
206+ assert_eq!(res.headers()[header::CONTENT_ENCODING], "br");
207+ let res = app
208+ .clone()
209+ .oneshot(req(format!(
210+ "/-/static/{hash}/fonts/space-mono-latin-400.woff2"
211+ )))
212+ .await
213+ .unwrap();
214+ assert!(res.headers().get(header::CONTENT_ENCODING).is_none());
215+}
A tests/index.rs View
@@ -0,0 +1,271 @@
1+//! Discovery, metadata precedence, hiding, the index page and live updates.
2+
3+mod common;
4+
5+use std::sync::Arc;
6+use std::time::{Duration, Instant};
7+
8+use axum::body::{Body, to_bytes};
9+use axum::http::{Request, StatusCode};
10+use common::{Work, bare, git, repo_with};
11+use ferrenium::config::Config;
12+use ferrenium::index::Refresh;
13+use ferrenium::server;
14+use ferrenium::state::{AppState, Overrides};
15+use tower::ServiceExt;
16+
17+/// `repos` holds extra `[repos]` keys; `extra` any further tables.
18+fn config(scan_path: &std::path::Path, repos: &str, extra: &str) -> Config {
19+ let toml = format!(
20+ "[repos]\nscan_path = {:?}\n{repos}\n[watch]\ndebounce_ms = 100\n{extra}",
21+ scan_path.to_str().unwrap()
22+ );
23+ let mut config = Config::parse(&toml).expect("parse");
24+ config.finish().expect("valid");
25+ config
26+}
27+
28+fn state(config: Config) -> Arc<AppState> {
29+ let state = AppState::new(config, Overrides::default());
30+ let config = state.config.load_full();
31+ state.index.full_scan(&config);
32+ state
33+}
34+
35+async fn page(state: &Arc<AppState>, uri: &str) -> String {
36+ let res = server::app(state.clone())
37+ .oneshot(Request::get(uri).body(Body::empty()).unwrap())
38+ .await
39+ .unwrap();
40+ assert_eq!(res.status(), StatusCode::OK);
41+ String::from_utf8(
42+ to_bytes(res.into_body(), usize::MAX)
43+ .await
44+ .unwrap()
45+ .to_vec(),
46+ )
47+ .unwrap()
48+}
49+
50+#[test]
51+fn metadata_precedence_and_hiding() {
52+ let root = tempfile::tempdir().unwrap();
53+ let r = root.path();
54+
55+ // description file only
56+ let (a, a_head) = repo_with(r, "alpha.git", &[("README.md", "# alpha\n")]);
57+ std::fs::write(a.join("description"), "From the description file\n").unwrap();
58+ // git config beats description file; gitweb.* is honored
59+ let (b, _) = repo_with(r, "forks/beta.git", &[("readme.txt", "beta\n")]);
60+ std::fs::write(b.join("description"), "ignored\n").unwrap();
61+ git(&b, &["config", "gitweb.description", "From gitweb"]);
62+ git(&b, &["config", "gitweb.owner", "bob"]);
63+ // ferrenium.* beats gitweb.*; TOML beats everything
64+ let (c, _) = repo_with(r, "gamma.git", &[("x.rs", "fn main() {}\n")]);
65+ git(
66+ &c,
67+ &["config", "ferrenium.description", "From ferrenium config"],
68+ );
69+ git(&c, &["config", "gitweb.description", "loses"]);
70+ git(&c, &["config", "ferrenium.section", "tools"]);
71+ // empty repo with git's placeholder description
72+ bare(r, "empty.git", &[]);
73+ // hidden three ways
74+ repo_with(r, "private/secret.git", &[("a", "a")]);
75+ let (h2, _) = repo_with(r, "byconfig.git", &[("a", "a")]);
76+ git(&h2, &["config", "ferrenium.hidden", "true"]);
77+ repo_with(r, "bytoml.git", &[("a", "a")]);
78+
79+ let state = state(config(
80+ r,
81+ "hide = [\"private/*\"]\nsection_from_path = true",
82+ r#"
83+ [[repo]]
84+ name = "gamma"
85+ description = "From TOML"
86+ [[repo]]
87+ name = "bytoml"
88+ hidden = true
89+ [[repo]]
90+ name = "nonexistent"
91+ "#,
92+ ));
93+ let index = state.index.load();
94+
95+ let names: Vec<_> = index.repos.keys().map(String::as_str).collect();
96+ assert_eq!(names, ["alpha", "empty", "forks/beta", "gamma"]);
97+ let hidden: Vec<_> = index.hidden.iter().map(String::as_str).collect();
98+ assert_eq!(hidden, ["byconfig", "bytoml", "private/secret"]);
99+
100+ let alpha = &index.repos["alpha"].meta;
101+ assert_eq!(
102+ alpha.description.as_deref(),
103+ Some("From the description file")
104+ );
105+ assert_eq!(alpha.default_branch.as_deref(), Some("main"));
106+ assert_eq!(alpha.head.unwrap().to_string(), a_head);
107+ assert_eq!(alpha.branches, 1);
108+ assert_eq!(alpha.readme.as_ref().unwrap().path, "README.md");
109+ assert!(alpha.last_activity.is_some());
110+ assert_eq!(alpha.section, None);
111+
112+ let beta = &index.repos["forks/beta"].meta;
113+ assert_eq!(beta.description.as_deref(), Some("From gitweb"));
114+ assert_eq!(beta.owner.as_deref(), Some("bob"));
115+ assert_eq!(beta.section.as_deref(), Some("forks")); // section_from_path
116+ assert_eq!(beta.readme.as_ref().unwrap().path, "readme.txt");
117+
118+ let gamma = &index.repos["gamma"].meta;
119+ assert_eq!(gamma.description.as_deref(), Some("From TOML"));
120+ assert_eq!(gamma.section.as_deref(), Some("tools"));
121+ assert_eq!(gamma.readme, None);
122+
123+ let empty = &index.repos["empty"].meta;
124+ assert_eq!(empty.description, None); // placeholder ignored
125+ assert_eq!(empty.head, None);
126+ assert_eq!(empty.default_branch.as_deref(), Some("main"));
127+ assert_eq!(empty.last_activity, None);
128+
129+ assert!(
130+ index.warnings.iter().any(|w| w.contains("\"nonexistent\"")),
131+ "{:?}",
132+ index.warnings
133+ );
134+}
135+
136+#[test]
137+fn reftable_repos_are_skipped_with_a_warning() {
138+ let root = tempfile::tempdir().unwrap();
139+ repo_with(root.path(), "normal.git", &[("a", "a")]);
140+ bare(root.path(), "rt.git", &["--ref-format=reftable"]);
141+ let state = state(config(root.path(), "", ""));
142+ let index = state.index.load();
143+ assert_eq!(index.repos.keys().collect::<Vec<_>>(), ["normal"]);
144+ assert!(
145+ index
146+ .warnings
147+ .iter()
148+ .any(|w| w.starts_with("rt:") && w.contains("reftable")),
149+ "{:?}",
150+ index.warnings
151+ );
152+}
153+
154+#[test]
155+fn unchanged_repos_keep_their_generation() {
156+ let root = tempfile::tempdir().unwrap();
157+ let (a, _) = repo_with(root.path(), "a.git", &[("f", "1")]);
158+ repo_with(root.path(), "b.git", &[("f", "1")]);
159+ let state = state(config(root.path(), "", ""));
160+ let cfg = state.config.load_full();
161+ let before = state.index.load();
162+
163+ let stats = state.index.full_scan(&cfg);
164+ assert!(!stats.any_change(), "{stats:?}");
165+ let after = state.index.load();
166+ assert!(Arc::ptr_eq(&before.repos["a"], &after.repos["a"]));
167+
168+ let work = Work::clone_of(&a);
169+ work.commit("f", "2", "change");
170+ work.push("main");
171+ assert_eq!(state.index.refresh(&cfg, &a), Refresh::Updated("a".into()));
172+ let refreshed = state.index.load();
173+ assert!(refreshed.repos["a"].generation > after.repos["a"].generation);
174+ assert!(Arc::ptr_eq(&after.repos["b"], &refreshed.repos["b"]));
175+ assert_eq!(state.index.refresh(&cfg, &a), Refresh::Unchanged);
176+}
177+
178+#[tokio::test]
179+async fn index_page_lists_sections_filter_and_sort() {
180+ let root = tempfile::tempdir().unwrap();
181+ let r = root.path();
182+ let (a, _) = repo_with(r, "zeta.git", &[("f", "1")]);
183+ git(
184+ &a,
185+ &["config", "ferrenium.description", "Last <b>letter</b>"],
186+ );
187+ git(&a, &["config", "ferrenium.section", "greek"]);
188+ repo_with(r, "alpha.git", &[("f", "1")]);
189+ repo_with(r, "with space.git", &[("f", "1")]);
190+ bare(r, "empty.git", &[]);
191+ let state = state(config(r, "", ""));
192+
193+ let html = page(&state, "/").await;
194+ assert!(html.contains("4 Repositories"));
195+ assert!(html.contains(r#"href="/alpha""#));
196+ assert!(html.contains(r#"href="/with%20space""#));
197+ assert!(html.contains("Last &#60;b&#62;letter&#60;/b&#62;") || html.contains("Last &lt;b&gt;"));
198+ assert!(html.contains("[ greek ]"));
199+ // Unsectioned repos come first, in name order.
200+ let pos = |s: &str| html.find(s).unwrap_or_else(|| panic!("{s} missing"));
201+ assert!(pos("/alpha\"") < pos("/empty\"") && pos("/empty\"") < pos("[ greek ]"));
202+ // Just pushed: fresh dot. Empty repo: no age.
203+ assert!(html.contains(r#"class="dot""#));
204+ assert!(html.contains(">Empty<"));
205+
206+ let filtered = page(&state, "/?q=LETTER").await;
207+ assert!(filtered.contains(r#"href="/zeta""#));
208+ assert!(!filtered.contains(r#"href="/alpha""#));
209+ assert!(filtered.contains(r#"value="LETTER""#));
210+
211+ let none = page(&state, "/?q=nothing-matches").await;
212+ assert!(none.contains("[No matches]"));
213+ assert!(!none.contains("data-filter-empty hidden"));
214+
215+ let by_activity = page(&state, "/?sort=activity").await;
216+ assert!(!by_activity.contains("[ greek ]"));
217+ assert!(by_activity.contains(r#"name="sort" value="activity""#));
218+}
219+
220+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
221+async fn push_updates_the_index_quickly() {
222+ let root = tempfile::tempdir().unwrap();
223+ let (repo, first) = repo_with(root.path(), "live.git", &[("f", "1")]);
224+ let state = state(config(root.path(), "", ""));
225+ tokio::spawn(ferrenium::index::watch::run(
226+ state.clone(),
227+ Default::default(),
228+ ));
229+ // Let the watcher install its watches.
230+ tokio::time::sleep(Duration::from_millis(200)).await;
231+
232+ let wait_for = |pred: &dyn Fn(&ferrenium::index::RepoIndex) -> bool| {
233+ let deadline = Instant::now() + Duration::from_secs(5);
234+ loop {
235+ if pred(&state.index.load()) {
236+ return Instant::now();
237+ }
238+ assert!(Instant::now() < deadline, "index did not update in time");
239+ std::thread::sleep(Duration::from_millis(20));
240+ }
241+ };
242+
243+ // A push to an existing repo.
244+ let work = Work::clone_of(&repo);
245+ let second = work.commit("f", "2", "second");
246+ assert_ne!(first, second);
247+ let pushed = Instant::now();
248+ work.push("main");
249+ let seen =
250+ wait_for(&|idx| idx.repos["live"].meta.head.map(|h| h.to_string()) == Some(second.clone()));
251+ let latency = seen - pushed;
252+ assert!(latency < Duration::from_secs(1), "took {latency:?}");
253+
254+ // A new branch counts too.
255+ git(
256+ work.path(),
257+ &["push", "-q", "origin", "main:refs/heads/feature/x"],
258+ );
259+ wait_for(&|idx| idx.repos["live"].meta.branches == 2);
260+
261+ // A new repository appears, a deleted one disappears.
262+ repo_with(root.path(), "nested/new.git", &[("f", "1")]);
263+ wait_for(&|idx| idx.repos.contains_key("nested/new"));
264+ std::fs::remove_dir_all(&repo).unwrap();
265+ wait_for(&|idx| !idx.repos.contains_key("live"));
266+
267+ // Hiding via git config takes effect without a restart.
268+ let new_dir = root.path().join("nested/new.git");
269+ git(&new_dir, &["config", "ferrenium.hidden", "true"]);
270+ wait_for(&|idx| idx.hidden.contains("nested/new"));
271+}