file ui review issues #69-76

dd30ef1gl0w

all commits

Message

file ui review issues #69-76

Diff

.forge/issues/0c944797-1025-43bb-a382-686669c85c69/issue.md+37 −0
diff --git a/.forge/issues/0c944797-1025-43bb-a382-686669c85c69/issue.md b/.forge/issues/0c944797-1025-43bb-a382-686669c85c69/issue.mdnew file mode 100644index 0000000..0b79885--- /dev/null+++ b/.forge/issues/0c944797-1025-43bb-a382-686669c85c69/issue.md@@ -0,0 +1,37 @@1+---2+id: 0c944797-1025-43bb-a382-686669c85c693+title: 'Build-time path traversal: forge frontmatter id/version writes outside the output dir'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:53:25.602484238Z10+updated: null11+---12+`hu13 build` writes forge pages using frontmatter values as path segments without validation, so a repo can write **arbitrary files outside the output directory**.13+14+Reproductions (all verified on master):15+16+```17+# .forge/issues/aaaa/issue.md with  id: ../../../../../../tmp/PWNED18+$ hu13 build . -o /tmp/trav-out19+$ ls /tmp/PWNED.html20+-rw-r--r-- 1 z z 1870 /tmp/PWNED.html21+22+# .forge/prs/bbbb/pr.md with  id: ../../../../../../tmp/PWNED2   -> /tmp/PWNED2.html23+# .forge/releases/r1.md with  version: /tmp/PWNED3              -> /tmp/PWNED3.html24+```25+26+Root causes:27+- `forge::render_loaded` -> `write_page(out, &format!("issues/{}.html", issue.id))` and the same for `pulls/{}.html`. `issue.id`/`pr.id` come straight from frontmatter (`fm.id`) and are never validated.28+- `releases::render_loaded` -> `out.join("releases").join(format!("{}.html", rel.version))`. `releases::load` guards *artifacts* against `/`, `\`, `..`, but `fm.version` itself is unvalidated — and `Path::join` with an absolute component discards `out` entirely.29+30+Impact: `build` runs in CI/Docker and writes with its own permissions. A hostile PR (or a repo you mirror but do not control) can drop attacker-influenced HTML anywhere the build user can write: overwrite other build outputs, plant files in a served tree, clobber dotfiles/config in the workspace. Because the content is a full issue/PR page, the body text is attacker-controlled (escaped HTML, but still attacker-chosen bytes and filename).31+32+Fix:33+- validate every frontmatter-used-as-path value: issue/pr `id` must match `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` (the f0rge uuid shape), release `version` must reject `/`, `\`, absolute paths and `..` components34+- defense in depth: one `write_page` helper that rejects any `rel` containing a `..` component or an absolute prefix, and asserts the final path stays under `out`35+- tests: traversal fixtures for issue id, pr id, release version; build must fail (or skip) and must not create the outside file36+37+Note: this predates #54 and is independent of it. It is the most serious finding of the review so far.
.forge/issues/0fea47fa-c30f-4181-8340-de5f33012d0b/issue.md+34 −0
diff --git a/.forge/issues/0fea47fa-c30f-4181-8340-de5f33012d0b/issue.md b/.forge/issues/0fea47fa-c30f-4181-8340-de5f33012d0b/issue.mdnew file mode 100644index 0000000..b227f28--- /dev/null+++ b/.forge/issues/0fea47fa-c30f-4181-8340-de5f33012d0b/issue.md@@ -0,0 +1,34 @@1+---2+id: 0fea47fa-c30f-4181-8340-de5f33012d0b3+title: 'Brand v1: gl0w + hu13 lockup, branded footer with gl0w.world, og:image, theme-aware logo'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:26:52.226727799Z10+updated: null11+---12+Today the site has **no gl0w branding anywhere** — zero references to gl0w.world in any generated page. The only attribution is the muted right-aligned footer line `generated from a snapshot of a repo and its f0rge · master @ afe4a2a`.13+14+Owner will vendor the gl0w logo asset; this issue wires it in and owns the lockup, footer, and metadata.15+16+### Current problems17+1. Header logo is `static/logo.svg` (96×32, 3:1) inside `.brand-lockup > img.site-logo` with `width:3rem;height:3rem`. The later rule `img,svg{max-width:100%;height:auto}` wins, so it renders **48×16** — an illegible chip in a 56px nav.18+2. `logo.svg` hardcodes `<rect fill="#1a1b26">` and a `#292e42` border, so **light mode shows a dark slab** (light theme page bg is `#d5d6db`).19+3. Footer: one muted line, right-aligned, no links, contrast 2.76:1 (dark) / 4.26:1 (light).20+4. No `og:image` (`og:title`/`og:type` exist) → link previews carry no brand.21+22+### Scope23+- **Header lockup**: gl0w mark + hu13 wordmark (e.g. `gl0w` glyph then `hu13`), sized to the nav (mark ≥ 24px, lockup ≤ 32px tall), linked to `/`. Keep the hu13 mark as the product identity; gl0w is the maker. Vendor the gl0w SVG under `static/` (owner supplies); if it needs a dark/light variant, emit both and swap with `@media (prefers-color-scheme)` or make it `currentColor`.24+- **Theme-aware logo**: remove the hardcoded background rect from `logo.svg` (or use `fill="none"` + `currentColor` strokes) so it works on both themes; regenerate `favicon.svg` if it shares the same rect.25+- **Footer**: left-aligned brand block — gl0w mark + `hu13 by gl0w` + `source at gl0w.world` (external link, `rel="noopener"`) — then the existing snapshot line (`generated from a snapshot … · branch @ sha`) demoted to small `--fg-dim`. One `footer.site-footer` per page, `page_site`/`page_site_with_provenance` both use it.26+- **`og:image`**: a static 1200×630 lockup (`static/og.png` or SVG), theme-neutral (dark bg is fine for previews), with `og:image:width/height` and `twitter:card=summary_large_image`.27+- **agents.md / README** should mention gl0w as the source (one line, not marketing).28+29+### Acceptance30+- [ ] header shows gl0w + hu13 legibly at 1280 and 390, in both themes31+- [ ] logo/favicon contain no hardcoded page background32+- [ ] every page's footer links to gl0w.world and still carries branch@sha33+- [ ] `og:image` present and resolves to a real file in `dist/`34+- [ ] footer text passes AA contrast (see the light-theme issue)
.forge/issues/1489b1c1-1014-4bb1-b28d-3eca6f811267/issue.md+38 −0
diff --git a/.forge/issues/1489b1c1-1014-4bb1-b28d-3eca6f811267/issue.md b/.forge/issues/1489b1c1-1014-4bb1-b28d-3eca6f811267/issue.mdnew file mode 100644index 0000000..62add8b--- /dev/null+++ b/.forge/issues/1489b1c1-1014-4bb1-b28d-3eca6f811267/issue.md@@ -0,0 +1,38 @@1+---2+id: 1489b1c1-1014-4bb1-b28d-3eca6f8112673+title: 'Responsive fixes: nav reachable at 390px, files table, clone summary wrapping'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:28:04.402586655Z10+updated: null11+---12+At 390px the site is usable-ish but broken in three specific places.13+14+### 1. Nav is unreachable15+`.site-nav` is `display:flex;flex-wrap:wrap` but `.nav-scroll-row` holds everything on one line, and `body{overflow-x:clip}` clips the overflow. At 390px only `code · commits · issues (14)` is visible — **pulls, ci, releases, wiki, agents cannot be reached**. No scroll affordance, no strip, no menu.16+17+### 2. Files table is cramped, not scrollable18+`.file-scroll` is deliberately exempt from the mobile card layout (m6/cards2, f0rge #46) and is supposed to scroll horizontally, but at 390px "Last commit" wraps to 3–4 lines per row while "Age" is clipped at the right edge. Row heights blow up, the table reads as broken rather than scrollable.19+20+### 3. Clone summary wraps mid-token21+`Clone & download · master @ afe4a2a · CI [pass]` wraps so `pass` lands alone on line 2, and the summary line uses `--muted` (below AA).22+23+### Also24+- Issue/PR meta blocks become six full-width lines on mobile (see the meta issue #72 — collapse to chips + `<details>`).25+- Touch targets: nav links are `min-height:40px` (good); table row links are text-only; `.btn-new` is ~40px tall (good).26+- No horizontal scroll indicator anywhere.27+28+### Scope29+- **Nav**: one-row horizontally scrollable strip with edge fade (`overflow-x:auto; scrollbar-width:none`) *or* wrap into two rows; ensure every tab is reachable at 320/390/768. Keep `aria-current`.30+- **Files table**: at `<768px` either drop the "Last commit" column (keep name + age, both real links), or let `.file-scroll` genuinely scroll with `width:max-content` and a scroll hint. Do not wrap commit subjects into 4 lines.31+- **Clone block**: `summary` becomes a two-line block on mobile (title, then `branch @ sha · CI badge`), no mid-token wrap (`white-space` per segment / flex-wrap with gap).32+- Test at 320, 360, 390, 768 (existing `tests/responsive.rs` can assert the rules, e.g. nav overflow-x and `.file-scroll` width behaviour).33+34+### Acceptance35+- [ ] all 8 nav destinations reachable at 390px36+- [ ] file table does not wrap commit subjects to >2 lines and does not clip the age column37+- [ ] clone summary badge never splits from its label38+- [ ] no page-level horizontal scroll at 390px
.forge/issues/158745a1-039d-4d34-b51a-1453268693ea/issue.md+36 −0
diff --git a/.forge/issues/158745a1-039d-4d34-b51a-1453268693ea/issue.md b/.forge/issues/158745a1-039d-4d34-b51a-1453268693ea/issue.mdnew file mode 100644index 0000000..2614c0d--- /dev/null+++ b/.forge/issues/158745a1-039d-4d34-b51a-1453268693ea/issue.md@@ -0,0 +1,36 @@1+---2+id: 158745a1-039d-4d34-b51a-1453268693ea3+title: 'build never cleans out/: deleted issues/files keep being served'4+state: open5+labels:6+- correctness7+assignees: []8+milestone: null9+created: 2026-10-27T14:54:17.130244919Z10+updated: null11+---12+`run_build` never cleans the output directory — it only `create_dir_all`s the subdirs it needs. Anything a previous build wrote and the current build no longer produces stays on disk and keeps being served.13+14+Verified:15+16+```17+$ hu13 build . -o out                       # issue 2222… exists -> issues/2222….html18+$ git rm -r .forge/issues/2222… && hu13 build . -o out19+$ ls out/issues/20+22222222-2222-4222-8222-222222222222.html    # still there, still served by `serve`/Pages21+$ grep -l "secret body" out/issues/*.html22+out/issues/22222222-2222-4222-8222-222222222222.html23+```24+25+Same for deleted repo files (`raw/`, `files/`), closed/renamed commits, removed wiki pages and releases. `repo.git` is removed explicitly since #54, everything else is not.26+27+Consequences: "delete an issue" and "close/rename a page" do not unpublish; a Pages deploy of a stale `dist/` quietly keeps removed content reachable by URL. For an inbox-adjacent project that is a privacy bug, not just tidiness.28+29+Fix options:30+- **clean by default**: remove `out` (or its known subtrees) at the start of `run_build`, with `--no-clean` for incremental debugging. The entrypoint always builds into a fresh `/srv/www`, so this is safe there.31+- or build into a temp dir and swap (atomic-ish), which also fixes half-written output when a build fails midway.32+33+Acceptance:34+- [ ] deleting an issue/file and rebuilding leaves no page for it in `out`35+- [ ] a failed build never leaves a half-updated served tree36+- [ ] documented: build cleans or the flag exists
.forge/issues/382214e0-86e3-4a06-851f-6d00e9d429ca/issue.md+34 −0
diff --git a/.forge/issues/382214e0-86e3-4a06-851f-6d00e9d429ca/issue.md b/.forge/issues/382214e0-86e3-4a06-851f-6d00e9d429ca/issue.mdnew file mode 100644index 0000000..5e6715c--- /dev/null+++ b/.forge/issues/382214e0-86e3-4a06-851f-6d00e9d429ca/issue.md@@ -0,0 +1,34 @@1+---2+id: 382214e0-86e3-4a06-851f-6d00e9d429ca3+title: 'Markdown: enable GFM tables + task lists + strikethrough (Options::empty() disables them)'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:27:05.553529409Z10+updated: null11+---12+`markdown_to_html` uses `Options::empty()`, so **GFM features are off**. Most visible consequences:13+14+1. **Tables render as literal pipes.** `agents.html` shows:15+   `| field | rule | |------|------| | `title` | required, plain text, <=300 chars | …`16+   Any README/wiki/issue with a table is unreadable.17+2. **Task lists render as literal `[ ]`** — `.forge` issues use `- [ ]` checklists everywhere (see any issue page).18+3. No strikethrough, no autolinks (`https://…` stays plain text), no footnotes.19+20+This is the cheapest perceived-quality win in the batch: three option flags.21+22+### Scope23+- `Options::ENABLE_TABLES | ENABLE_TASKLISTS | ENABLE_STRIKETHROUGH | ENABLE_FOOTNOTES` (drop footnotes if it complicates the CSS budget; tables + task lists + strikethrough are the must-haves; add `ENABLE_GFM`? check the pulldown-cmark 0.13 API).24+- Style them: `table` (border-collapse, `--border`, `--bg-th` header like the existing data tables), `th/td` padding matching the file tables, `input[type=checkbox]` inline + disabled look (no JS), `del` in `--fg-dim`, `.footnotes` small.25+- Task list checkboxes: pulldown-cmark emits `<input disabled type="checkbox">`; ensure they are `disabled` (they are) and don't inherit the big form input styling.26+- **Do the URL scheme filter from #62 in the same pass** — same `Event`/`Tag` mapping function, one edit.27+- Tests: table → `<table>`; `- [x]` → checked checkbox; `~~x~~` → `<del>`; `https://x` linkified only if autolinks enabled.28+29+### Acceptance30+- [ ] `agents.html` renders a real table31+- [ ] task-list items show checkboxes (checked/unchecked)32+- [ ] blockquote/table inside lists still looks right33+- [ ] `javascript:` links blocked (see #62)34+- [ ] new table CSS stays inside the (raised) CSS budget
.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md+34 −0
diff --git a/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.mdnew file mode 100644index 0000000..ff9c43e--- /dev/null+++ b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md@@ -0,0 +1,34 @@1+---2+id: 4491edd4-3697-437c-9ff7-98235bf1c7433+title: repo.git is 260 files / 226 loose objects (Pages 20k-file cap, dumb clone = 1 GET per object)4+state: open5+labels:6+- perf7+assignees: []8+milestone: null9+created: 2026-10-27T14:47:44.637520264Z10+updated: null11+---12+Follow-up to #55 (same root cause, different symptom). The export path produces a bloated multi-file artifact.13+14+Measured on this repo (318 tracked files):15+16+```17+today (git clone --bare):       files=260  du=2.2M  loose=226  packs=318+bundled (clone from .bundle):   files=24   du=796K  loose=0    packs=119+```20+21+Two costs:22+23+1. Dumb HTTP fetches loose objects **one request each**: a clone of the current export issues 200+ GETs for a toy repo. Great for nobody, pathological on a high-latency link.24+2. Cloudflare Pages free tier limits a deployment to 20,000 files and 25 MiB per file. A mid-size repo (thousands of commits, loose objects) exceeds the file cap and the deploy fails outright. This is the exact target platform for the dumb-clone design.25+26+Same fix as #55: build `repo.git` from the existing `repo.bundle`, which yields exactly one pack and no loose objects.27+28+Verified: packed-only export + `update-server-info` serves and clones correctly (`git clone -b master http://127.0.0.1:39401/repo.git` -> tip matches).29+30+Acceptance:31+- [ ] a build's `repo.git` contains exactly 1 `.pack` and 0 loose objects (`git count-objects -v` shows `count: 0`)32+- [ ] total file count under `repo.git` stays < 40 for the fixture repo (hooks/description noise removed)33+- [ ] `git clone <origin>/repo.git` still works over `hu13 serve`34+- [ ] docs state the Pages limits and that a single pack keeps us under them
.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md+24 −0
diff --git a/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.mdnew file mode 100644index 0000000..37b39d2--- /dev/null+++ b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md@@ -0,0 +1,24 @@1+---2+id: 462c4759-677d-49bb-be01-cf267dbbd1063+title: 'Clone docs + repo hygiene after #54 (Dockerfile, wiki/deploy, .gitignore, unset-mode UX)'4+state: open5+labels:6+- docs7+assignees: []8+milestone: null9+created: 2026-10-27T14:48:05.298412934Z10+updated: null11+---12+Small leftovers from the #54 review — docs and repo hygiene.13+14+1. `Dockerfile:37` still documents the old contract: "set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`". It advertises `<origin>/repo.git` now.15+2. `.forge/wiki/deploy` does not mention `repo.git` at all, and its env table omits `HU13_PUBLIC_ORIGIN` entirely (flag exists, entrypoint passes it, docs never say so). It also still frames `HU13_CLONE_URL` as "the clone URL advertised on the index", which is now the fallback/mirror label.16+3. `.gitignore` has no `/dist`. Since `dist/repo.git` is a 200+-file directory, running `hu13 build . -o dist` in the repo now scatters hundreds of derived files into a tracked-looking path. Add `/dist` (and a `.forge/wiki/deploy` line about building outside the repo).17+4. Unset-origin clone block now advertises two clone methods back to back: `git clone repo.git -b <branch>` and `git clone repo.bundle -b <branch>`. `repo.git` is a *path*, not a URL, and the adjacent note "ask the host for its address" reads oddly next to something that looks cloneable. Pick one primary (bundle stays authoritative when unset, per the existing contract) and mention `repo.git` as the served-directory form.18+5. `serve::content_type_for` matches `lower.ends_with("/head")`, i.e. any file named `head` anywhere. Match the bare-repo file exactly (`/HEAD`, `info/refs`, `info/packs`, `packed-refs`) to avoid teaching the mime table about arbitrary paths.19+20+Acceptance:21+- [ ] Dockerfile + wiki/deploy describe `<origin>/repo.git` and document HU13_PUBLIC_ORIGIN22+- [ ] `/dist` ignored23+- [ ] unset-origin clone block has one primary method24+- [ ] content-type rule anchored to real bare-repo filenames
.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/comments/02802032-bee6-4033-a67c-0552eb20b27c.md+6 −0
diff --git a/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/comments/02802032-bee6-4033-a67c-0552eb20b27c.md b/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/comments/02802032-bee6-4033-a67c-0552eb20b27c.mdnew file mode 100644index 0000000..9b2f035--- /dev/null+++ b/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/comments/02802032-bee6-4033-a67c-0552eb20b27c.md@@ -0,0 +1,6 @@1+---2+id: 02802032-bee6-4033-a67c-0552eb20b27c3+author: null4+created: 2026-10-27T14:54:03.105236805Z5+---6+Correction: the cross-reference should point at #64 (upload-pack timeout/cap), not this issue. Same resource class: thread/subprocess exhaustion in serve.
.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/issue.md+39 −0
diff --git a/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/issue.md b/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/issue.mdnew file mode 100644index 0000000..605b174--- /dev/null+++ b/.forge/issues/476e0fbb-25b5-4051-92ae-8e38a1fef02e/issue.md@@ -0,0 +1,39 @@1+---2+id: 476e0fbb-25b5-4051-92ae-8e38a1fef02e3+title: 'serve: unbounded thread-per-connection + 500ms ack sleep = trivial availability DoS'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:53:46.365269127Z10+updated: null11+---12+`hu13 serve` is thread-per-connection with no cap, and every inbox response sleeps 500 ms.13+14+Verified:15+16+```17+before: 1 thread18+$ python3 open 300 idle sockets sending "GET / HTTP/1.1\r\nHost: x\r\n" (no terminator)19+during: 301 threads   VmRSS 11.8 MB20+```21+22+Those sockets cost nothing per connection and can be kept alive indefinitely (a byte every < read timeout). ~10-30k cheap sockets exhaust the thread/pid limit or memory, and the server stops accepting.23+24+Amplifiers:25+- inbox handler always `sleep(ACK_DELAY_MS)` (500 ms) before answering, on every path including 400/403/429 — a rejected POST holds a thread for half a second26+- `settle()` will additionally read up to 256 KiB from a lying `Content-Length` before answering27+- no accept-backlog tuning, no per-IP connection limit, no global connection cap28+29+Related, same class but separate resource: #63 (`git upload-pack` with no timeout/cap).30+31+Fix direction:32+- cap concurrent connections (e.g. semaphore of 64-256) and answer 503 immediately when exhausted33+- keep the fixed ack delay for *semantic* uniformity, but do not let it pin a thread: either keep it (with the cap above) or use a small timer wheel; the cap is the simpler fix34+- optionally lower `POST_READ_TIMEOUT_SECS` and require the head within a shorter window than the body35+36+Acceptance:37+- [ ] N idle sockets beyond the cap get refused/closed, not thread-per-connection38+- [ ] process thread count has a documented upper bound39+- [ ] a test/bench opens >cap sockets and asserts the server still answers `/`
.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md+41 −0
diff --git a/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.mdnew file mode 100644index 0000000..93e34dd--- /dev/null+++ b/.forge/issues/4ab82678-f954-4bef-bfbc-eed37ab38bde/issue.md@@ -0,0 +1,41 @@1+---2+id: 4ab82678-f954-4bef-bfbc-eed37ab38bde3+title: 'Stored XSS: raw/<path> serves committed HTML/SVG as text/html'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:53:32.418656561Z10+updated: null11+---12+`dist/raw/<path>` is byte-identical repo content served with a content type chosen from the *extension*, so any committed `.html` (or `.svg`, `.xhtml`, `.xml`) executes script on the site's origin.13+14+Verified:15+16+```17+$ printf '<!doctype html><script>fetch("/inbox/issue",{method:"POST"})</script>hi\n' > evil.html18+$ hu13 build . -o out && hu13 serve --www out --inbox-dir /tmp/ib19+$ curl -D- http://127.0.0.1:39501/raw/evil.html20+HTTP/1.1 200 OK21+Content-Type: text/html; charset=utf-822+...23+<!doctype html><script>fetch("/inbox/issue",{method:"POST"})</script>hi24+```25+26+`files/evil.html.html` is correctly escaped (`&lt;script&gt;`), so the escaped view is fine — the raw view is the hole. It needs no click: visiting the URL runs it.27+28+Why it matters here specifically: the page is same-origin with `/inbox/*`, so injected script satisfies the `Origin` gate, can POST spam from the visitor's own IP, has full access to the DOM of the mirror (phishing/defacement), and can chain the inbox forms. It also breaks the project invariant "bodies from the repo are treated as untrusted".29+30+Same hole exists on Cloudflare Pages (it also types by extension), so this cannot be fixed in `serve` alone.31+32+Fix direction:33+- serve `/raw/**` as `application/octet-stream` + `Content-Disposition: attachment; filename="..."` for anything that browsers render (html/htm/svg/xhtml/xml/xsl/mht), `text/plain; charset=utf-8` for text files34+- never `text/html`, `image/svg+xml` or `application/xhtml+xml` under `/raw/`35+- Pages: emit `_headers` with the same rule (or drop the `raw/` tree from Pages builds)36+- optionally add `Content-Security-Policy: default-src 'none'; style-src 'self'` on raw responses as belt-and-braces37+38+Acceptance:39+- [ ] `GET /raw/x.html` is never `text/html` and does not execute script40+- [ ] text files still render readably as `text/plain`41+- [ ] a test builds a repo containing `<script>` in `.html`/`.svg` and asserts the served content type
.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md+36 −0
diff --git a/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.mdnew file mode 100644index 0000000..cd965c1--- /dev/null+++ b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md@@ -0,0 +1,36 @@1+---2+id: 50c21acd-1a7e-4e98-84e9-f1d07e6476413+title: repo.git leaks unreachable objects (not an exact snapshot)4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:47:37.930020568Z10+updated: null11+---12+Adversarial review of #54 found a real information leak: `dist/repo.git` is not the snapshot it claims to be.13+14+`export_dumb_repo` runs `git clone --bare <repo>`, which for a local path uses the *local* transport: git copies/hardlinks the entire source object database instead of transferring only objects reachable from the selected branch. `--single-branch` limits refs, not objects.15+16+Verified on this repo:17+18+```19+$ git clone -q -b master http://127.0.0.1:39313/repo.git /tmp/httpclone20+$ git -C /tmp/httpclone cat-file -e 0f20352a1fab8155e35e67b32602b90c93dd9d3a && echo PRESENT21+PRESENT22+$ git -C /tmp/httpclone fsck --no-progress | grep -c dangling23+624+```25+26+`git fsck` on the published artifact reports 61 dangling objects; a plain HTTP clone inherits 6 unreachable commits, e.g. `merge 3c5c330e-…` (2026-09-10) which is not on master.27+28+Impact: force-pushed-away commits, deleted branches, amended commits and any blob ever committed become public to anyone who clones and runs `git fsck`/`git cat-file`. That includes dropped `.forge/` internals. The clone block simultaneously claims "exact snapshot of <branch> @ <sha>", which is false.29+30+Fix: derive `repo.git` from the bundle `snapshot()` already writes, i.e. `git clone --bare --single-branch --branch <b> <out>/repo.bundle <out>/repo.git` (bundle transport negotiates reachable objects only), then `update-server-info`. Verified on this repo: 0 dangling, 1 pack, 24 files.31+32+Acceptance:33+- [ ] exporting a repo that contains unreachable objects yields a clone with `git fsck` reporting no dangling objects34+- [ ] malicious-ish fixture: commit a tree, amend it, export -> old commit is NOT in the export35+- [ ] tip sha in `repo.git` equals the sha advertised in the clone block (short sha `rev` on index)36+- [ ] test asserts `git -C <out>/repo.git fsck` has no `dangling` lines
.forge/issues/7b0b0eae-93d2-4cb3-8fa5-e01cbf1447ac/issue.md+41 −0
diff --git a/.forge/issues/7b0b0eae-93d2-4cb3-8fa5-e01cbf1447ac/issue.md b/.forge/issues/7b0b0eae-93d2-4cb3-8fa5-e01cbf1447ac/issue.mdnew file mode 100644index 0000000..4863cf1--- /dev/null+++ b/.forge/issues/7b0b0eae-93d2-4cb3-8fa5-e01cbf1447ac/issue.md@@ -0,0 +1,41 @@1+---2+id: 7b0b0eae-93d2-4cb3-8fa5-e01cbf1447ac3+title: 'Markdown link/image URLs are not scheme-filtered (javascript: XSS)'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:53:39.317738326Z10+updated: null11+---12+Markdown link and image destinations are emitted verbatim, so `[click](javascript:...)` renders as a live `javascript:` link on the mirror.13+14+Verified with a committed README:15+16+```17+[click me](javascript:alert(document.domain))18+![img](javascript:alert(2))19+```20+21+```22+$ grep -o '<a href="[^"]*"' out/index.html23+<a href="javascript:alert(document.domain)"24+$ grep -o '<img src="[^"]*"' out/index.html25+<img src="javascript:alert(2)"26+```27+28+`markdown_to_html` intercepts `Event::Html` / `Event::InlineHtml` and escapes them (good — raw `<script>` in markdown is neutralized), but `Tag::Link { dest_url }` / `Tag::Image { dest_url }` pass through untouched. pulldown-cmark does not sanitize URLs by design.29+30+Impact: stored XSS one click away from README, wiki pages, issue/PR bodies and release notes — same origin as `/inbox/*`, so any injected script can post submissions with the visitor's IP. Complements #61 (which needs no click) — fixing both closes the pair.31+32+Fix: keep intercepting events in `markdown_to_html` and add a `Tag` arm that rewrites `Link`/`Image` destinations through an allowlist:33+- allow `http:`, `https:`, `mailto:`, and relative/fragment/`//host` forms34+- everything else (javascript:, data:, vbscript:, file:) renders with an empty/`#` href or as plain text35+- note `esc` does not percent-encode, so decide explicitly whether to encode the URL (currently `#`/`?`/space in a filename break the link, a separate correctness nit)36+37+Acceptance:38+- [ ] `[x](javascript:alert(1))` never reaches an `href`/`src`39+- [ ] `data:` and `vbscript:` likewise40+- [ ] legit links (relative files, https, mailto) unchanged41+- [ ] unit test on `markdown_to_html` covering each scheme
.forge/issues/7fa9434e-8f1f-4e63-8236-c039237b073a/issue.md+54 −0
diff --git a/.forge/issues/7fa9434e-8f1f-4e63-8236-c039237b073a/issue.md b/.forge/issues/7fa9434e-8f1f-4e63-8236-c039237b073a/issue.mdnew file mode 100644index 0000000..5602348--- /dev/null+++ b/.forge/issues/7fa9434e-8f1f-4e63-8236-c039237b073a/issue.md@@ -0,0 +1,54 @@1+---2+id: 7fa9434e-8f1f-4e63-8236-c039237b073a3+title: 'Diffs: per-file cards, dual line numbers, visual diffstat, drop mbox preamble'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:27:50.501775126Z10+updated: null11+---12+Commit detail and PR patch sections dump raw `git show` / `git format-patch` output into one `<pre>`. This is where GitHub/GitLab/Forgejo look most polished and we look like `less`.13+14+### Current15+Commit page (`commit/<sha>.html`):16+```17+commit 030789738ad2cd3099f5764bc5e4aa104367362618+Author: gl0w <gl0w@world>19+Date:   Thu Sep 10 05:26:49 2026 +010020+21+    land #7 forge-readers + f0rge state22+23+diff --git a/.forge/issues/f8c1…/issue.md b/.forge/issues/f8c1…/issue.md24+index 2a46910..97d6ad7 10064425+--- a/…26++++ b/…27+@@ -1,13 +1,13 @@28+```29+PR page patch starts with the mbox preamble `From 4eabe68… Mon Sep 17 00:00:00 2001`, `From:`, `Subject: [PATCH] …`, then a text diffstat (`src/commits.rs | 30 +++++…`).30+31+Problems:32+1. No per-file grouping/cards — one giant block.33+2. No line numbers (neither old nor new side).34+3. `diff --git` / `index` / `---` / `+++` are colored with `--diff-file`, which equals `--fg` → visually indistinguishable from body text.35+4. Hunk headers (`@@ … @@`) are plain; no function context emphasis.36+5. Long paths wrap mid-token instead of scrolling per file.37+6. Mbox/patch preamble is pure noise on the PR page.38+7. Commit header (`Author:`, `Date:`) duplicates the meta shown above the diff.39+40+### Scope41+- Emit **one card per file**: sticky file header (path, `+n −m` counts in green/red, "view file" link), collapse-all via `<details open>`.42+- **Two-column line numbers** (old/new) in a gutter, `--fg-dim`, `user-select:none`, monospace alignment with the code lines.43+- **Diffstat bar**: a real visual bar (green/red proportional divs) replacing `++++---` ASCII.44+- Color `---`/`+++`/`diff --git` with a distinct `--diff-file` token (not `--fg`), hunk headers `--diff-hunk` on a `--surface-2` strip.45+- Strip the `From …` mbox preamble and `From:`/`Subject:` lines on the PR page (they duplicate the PR title); keep the commit message body only.46+- Per-file horizontal scroll (`overflow-x:auto`) instead of wrapping paths.47+- Rust: `commits.rs` already emits `diff-file`/`diff-hunk` spans; extend to file boundaries + line numbers rather than re-parsing.48+49+### Acceptance50+- [ ] every changed file renders as its own card with path + counts51+- [ ] old/new line numbers align with code lines52+- [ ] `diff --git`/`---`/`+++` visibly distinct from code text53+- [ ] PR patch has no mbox preamble54+- [ ] no mid-token wrapping of long paths (scroll instead)
.forge/issues/80bf7564-337c-424b-a06d-362c9e18373c/issue.md+39 −0
diff --git a/.forge/issues/80bf7564-337c-424b-a06d-362c9e18373c/issue.md b/.forge/issues/80bf7564-337c-424b-a06d-362c9e18373c/issue.mdnew file mode 100644index 0000000..1e8f7f4--- /dev/null+++ b/.forge/issues/80bf7564-337c-424b-a06d-362c9e18373c/issue.md@@ -0,0 +1,39 @@1+---2+id: 80bf7564-337c-424b-a06d-362c9e18373c3+title: 'RateLimiter: per-key map never evicts, so source-IP rotation grows memory without bound'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:54:03.114650245Z10+updated: null11+---12+`RateLimiter` keeps a `HashMap<String, Vec<u64>>` and only prunes the entry for the key being checked:13+14+```rust15+fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool {16+    let key = salted_ip_hash(ip, date, server_salt);17+    let mut map = self.buckets.lock()...;18+    let entry = map.entry(key).or_default();19+    entry.retain(|&t| now.wrapping_sub(t) < 3600);   // this key only20+    ...21+}22+```23+24+Keys are never removed and there is no global sweep, so every distinct source IP that ever submits adds a permanent entry. Two facts make this cheap to grow:25+26+1. `limiter.check()` is called **before** `check_quota_with()` and **before** `store_submission`, so a key is created and a timestamp recorded even when the POST is refused (quota full → 503).27+2. Every request costs the attacker only a connection; source addresses are free on IPv6 (a single /64 is 2^64 addresses) and trivially varied.28+29+Result: unbounded, monotonic memory growth on a public deployment; the process never returns the memory (HashMap never shrinks). A few million connections = hundreds of MB, and it is not reclaimable without a restart.30+31+Fix direction:32+- expiry sweep: on insert, drop entries whose newest timestamp is older than the window (or `map.retain` when `map.len()` crosses a threshold)33+- optionally cap the number of tracked keys; when full, fall back to a single shared bucket (fail closed) rather than growing34+- move `limiter.check` after the cheap rejections if you want refused submissions not to create keys (does not fix rotation, only slows it)35+36+Acceptance:37+- [ ] entries older than the window are evicted without a request from that IP38+- [ ] a test inserts N distinct keys, advances the clock past the window, asserts the map shrinks39+- [ ] map size has a documented bound
.forge/issues/9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a/issue.md+32 −0
diff --git a/.forge/issues/9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a/issue.md b/.forge/issues/9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a/issue.mdnew file mode 100644index 0000000..a4a0724--- /dev/null+++ b/.forge/issues/9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a/issue.md@@ -0,0 +1,32 @@1+---2+id: 9a9e58cd-9994-44c9-a8ca-fc3bbdd2f04a3+title: 'Code view: add build-time syntax highlighting (no JS) + line-number gutter + file toolbar'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:27:35.641700257Z10+updated: null11+---12+The file view has **zero token coloring** — `static/style.css` contains no highlight rules (`grep -c tok- static/style.css` = 0) and file pages emit plain `<span class="line">` text. A code mirror without highlighting is the single biggest "worse than GitHub/GitLab/Forgejo" gap.13+14+### Current file view15+- `files/src/build.rs.html`: flat grey monospace, 13px/19px, with a floating line-number gutter that has no separator rule and uses `--muted` (2.76:1 dark / 4.26:1 light).16+- `h1` repeats the breadcrumb path (`root / src / build.rs` then `<h1>src/build.rs</h1>`).17+- Meta bar: `src/build.rs · 94.3 KiB · 2333 lines · Raw` with "Raw" as plain text.18+- No wrap toggle, no copy button, no syntax hints.19+20+### Scope21+- **Tokenize at build time** (no JS, no deps beyond what we have) for a small language set: Rust, JS/TS, JSON, YAML, TOML, Markdown, shell, HTML/CSS, Python, Go. A ~200-line lexer producing `tok-kw/tok-str/tok-num/tok-com/tok-fn/tok-type` spans is enough; unknown extensions stay plain.22+- **Tokyo Night palette**: `--tok-kw` (purple/magenta), `--tok-str` (green), `--tok-num` (orange), `--tok-com` (muted italic), `--tok-fn` (blue), `--tok-type` (cyan). Must pass contrast in both themes.23+- **Gutter**: sticky line-number column, `--fg-dim`, right-aligned, 1px `--border` rule between numbers and code, `user-select:none`, anchors preserved (`id="L42"`).24+- **File toolbar**: breadcrumb (drop the duplicated `h1`, or keep a short `build.rs`), `Raw` as a proper secondary button, size + line count as dim chips, and a "wrap" `<details>`-free toggle is impossible without JS — so instead: `white-space:pre` + horizontal scroll, plus a `<a href="/raw/…">` for full text.25+- Keep output byte-budget: tokens add ~15–30% to file page size; measure on `src/build.rs` (94 KiB → ~120 KiB) and confirm acceptable, or cap highlighting to files < N KiB.26+27+### Acceptance28+- [ ] Rust/JS/JSON/YAML/TOML/MD/shell files render colored tokens29+- [ ] unknown extensions and huge files stay plain (no regression)30+- [ ] line anchors still work and are selectable/shareable31+- [ ] contrast of every token color ≥ 4.5:1 in both themes32+- [ ] build-time cost stays negligible (no new deps)
.forge/issues/b085165e-9006-4ef4-a85f-ad58b2946280/issue.md+39 −0
diff --git a/.forge/issues/b085165e-9006-4ef4-a85f-ad58b2946280/issue.md b/.forge/issues/b085165e-9006-4ef4-a85f-ad58b2946280/issue.mdnew file mode 100644index 0000000..55079b0--- /dev/null+++ b/.forge/issues/b085165e-9006-4ef4-a85f-ad58b2946280/issue.md@@ -0,0 +1,39 @@1+---2+id: b085165e-9006-4ef4-a85f-ad58b29462803+title: Light theme retune + AA contrast pass (--muted is 2.76:1 dark / 4.26:1 light)4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:28:20.051026328Z10+updated: null11+---12+The dark theme (Tokyo Night) is the designed one and reads well. The **light theme is the weak one**: page `#d5d6db`, header `#b4b8c7`, body `#343b58` on a dusty blue-grey field — it looks like a 2010s admin panel, everything on one flat tone with no surface separation. It is also the theme most first-time visitors on desktop will get.13+14+Measured contrast (WCAG):15+16+| pair | ratio | |17+|---|---|---|18+| dark `--fg` #c0caf5 on `--bg` #1a1b26 | 10.59:1 | ok |19+| dark `--muted` #565f89 on `--bg` #1a1b26 | **2.76:1** | fails AA |20+| dark `--muted` on `--bg-alt` #16161e | **2.91:1** | fails AA |21+| light `--fg` #343b58 on `--bg` #d5d6db | 7.57:1 | ok |22+| light `--muted` #565f89 on `--bg` #d5d6db | **4.26:1** | fails AA |23+| light `--muted` on `--bg-th` #b4b8c7 | **3.13:1** | fails AA |24+| light `--blue` link #34548a on `--bg` | 5.20:1 | ok |25+26+`--muted` is used for all small text: footer/provenance, meta lines, breadcrumbs, line numbers, comment authors, "from .forge/wiki/README", diff `index` lines. It is the most-used color after `--fg` and it is the least legible.27+28+### Scope29+- **Light palette**: move to a real light product palette — `--bg:#f6f7f9` (or white), `--surface:#ffffff`, `--surface-2:#eef0f4`, `--bg-alt:#eceef2` header, `--border:#d6dae2`, keep `--fg` dark and `--blue`/`--cyan` deep enough for AA. The point is *surface separation*: content panels sit on a lighter surface than the header/background.30+- **`--muted` split**: introduce `--fg-dim` that passes 4.5:1 in both themes (e.g. dark `#9aa5ce`-ish is 7.5:1; light `#5a6383` ~5:1) and use it for body-adjacent meta; keep the current `--muted` only for non-essential decoration, if at all.31+- Re-check every token used for text: state pills, badge colors (`--green`/`--red`/`--yellow` in light mode are currently dark-on-light and probably pass, verify), diff add/del backgrounds, token colors from the highlighting issue.32+- **`color-scheme`** already declares `dark light`; keep respecting the OS preference (no JS toggle) but make both themes first-class.33+- Add a tiny test asserting the palette values stay in sync between `:root` and the light media query (both must define every token), plus a contrast helper test for `--fg`/`--fg-dim`/link colors against their backgrounds.34+35+### Acceptance36+- [ ] every color used for text passes 4.5:1 in both themes (computed in a test, not by eye)37+- [ ] light theme has visible surface separation (header ≠ page ≠ card)38+- [ ] no hardcoded hex outside `:root`/media query in new rules39+- [ ] screenshots at 1280/390 in both themes attached to the PR
.forge/issues/bd97c1d0-b90e-45df-b3e9-ab1182f8382a/issue.md+45 −0
diff --git a/.forge/issues/bd97c1d0-b90e-45df-b3e9-ab1182f8382a/issue.md b/.forge/issues/bd97c1d0-b90e-45df-b3e9-ab1182f8382a/issue.mdnew file mode 100644index 0000000..d99b7bc--- /dev/null+++ b/.forge/issues/bd97c1d0-b90e-45df-b3e9-ab1182f8382a/issue.md@@ -0,0 +1,45 @@1+---2+id: bd97c1d0-b90e-45df-b3e9-ab1182f8382a3+title: 'Meta/date/authors pass: state pills, label chips, monograms, relative dates, demote internal ids'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:27:21.526717764Z10+updated: null11+---12+Issue, PR, CI and commit pages render raw data-model fields as `label: value` lines. It reads as a debug dump, not a product.13+14+### Evidence15+Issue page (`issues/<uuid>.html`), before any content:16+```17+state: closed18+labels: area:docker19+milestone: m5-docker-v120+created: 2026-09-10T03:52:27.769090401Z21+updated: 2026-09-10T05:09:33.503159534Z22+id: 06b5602f-4327-4377-99f6-3dca67b5bd7523+```24+≈180px of vertical space, six full-width lines. The UUID is internal plumbing; the timestamps are nanosecond ISO.25+26+Same pattern:27+- **PR**: `state: merged`, `branches: master ← m6/table2`, `ci: [pass]`, `created/updated: <ISO>`, `id: <uuid>`28+- **CI**: `runner: local`, `verdict: [pass]`, `run: 2026-10-27T14-08-12.402568073Z`, `created: …`, and a table whose headers are the literal field names `step | exit | duration_ms`29+- **Commits index**: `2026-10-27 14:08:48 +0000` per row; **issues/pulls index**: `created 2026-10-27T14:54:31.66872…` truncated mid-digit30+- **Comments**: `2026-09-10T05:09:33.499801243Z   unknown`31+32+### Scope33+- **One meta component** for issue/PR/CI/commit: a compact chip row or a right-hand sidebar (desktop) / collapsible `<details>` (mobile) with: state pill (`open`/`closed`/`merged`, colored), label chips, milestone chip, author monogram + handle, "opened 3 days ago" (relative, `title`=exact UTC), "updated …".34+- **State pills** using `--state-open/--state-merged/--state-closed` tokens (add in the foundations issue). CI `pass`/`fail`/`running` badges already exist — reuse that shape.35+- **Author monogram**: 1–2 letters in a square, deterministic hue from the handle hash (no assets, no JS). Applies to comments and the commits table.36+- **Relative dates everywhere** with the exact value in `title=` (pattern already exists: `relative_age` in `build.rs`). Never show nanoseconds; never truncate an ISO string.37+- **Internal ids**: keep them, but demote to a monospace detail at the bottom of the sidebar (`#id 06b5…` with a copy affordance-free `title`), not a headline field. CI `duration_ms` → `duration` + "ms" unit or humanized (`245 ms`), and pick user-facing labels (`step`, `exit`, `duration`).38+- Don't lose data: every field currently shown must remain reachable (sidebar or `<details>`).39+40+### Acceptance41+- [ ] issue/PR pages start with title + state pill + meta row; no `id:`/`created:` full-width lines42+- [ ] no raw ISO/nanosecond timestamps anywhere in HTML (relative + `title` exact)43+- [ ] CI table headers are `step | exit | duration`, values include units44+- [ ] comments show a monogram + `handle · 3 days ago`45+- [ ] mobile: meta collapses to one line + `<details>`
.forge/issues/db42bcef-a612-436f-9d14-72540f8acf0a/issue.md+34 −0
diff --git a/.forge/issues/db42bcef-a612-436f-9d14-72540f8acf0a/issue.md b/.forge/issues/db42bcef-a612-436f-9d14-72540f8acf0a/issue.mdnew file mode 100644index 0000000..d4ab833--- /dev/null+++ b/.forge/issues/db42bcef-a612-436f-9d14-72540f8acf0a/issue.md@@ -0,0 +1,34 @@1+---2+id: db42bcef-a612-436f-9d14-72540f8acf0a3+title: 'Small robustness/consistency batch: ''.'' repo name, gitlink pages, inbox list escaping, content-type anchor, symlink gap'4+state: open5+labels:6+- polish7+assignees: []8+milestone: null9+created: 2026-10-27T14:54:31.668727942Z10+updated: null11+---12+Smaller robustness/consistency items found in the same review. Each is independently shippable.13+14+1. **`hu13 build .` loses the repo name.** `resolve_repo_name` uses `repo.file_name()`, which is `None` for `"."`, so the site is titled "repo" instead of the directory name. Cosmetic, but `.` is the obvious invocation and it silently degrades the header. Fix: canonicalize (or `repo.absolutize`) before taking `file_name`, or fall back to the current dir name when `None`.15+16+2. **Submodule (gitlink) entries render as a bogus file.** A tree entry with mode `160000` is not a blob; `git show <branch>:<path>` on it returns the *commit object* text, so the build writes:17+   - `raw/vendor/dep` = `commit a5e5260…\nAuthor: …` (looks like file content, is not)18+   - `files/vendor/dep.html` = a file page containing that text19+   Fix: skip/detach gitlinks in `ls_tree` (mark them), render a "submodule at <sha>" page, and never write them to `raw/`.20+21+3. **`inbox list` prints the id unescaped.** `cmd_list` does `println!("{} [{}] {} {}", e.id, escape_terminal(kind), …)` — every other field goes through `escape_terminal`, `e.id` comes from the file stem. A `*.json` file dropped/pulled into the inbox dir with a name containing ESC/control bytes prints raw ANSI. `cmd_show` escapes its id, so this is just an inconsistency: escape it too.22+23+4. **`content_type_for` matches `ends_with("/head")`.** That is any file named `head` anywhere in the tree, not just a bare repo's `HEAD`. Anchor the rule to the real paths (`"repo.git/HEAD"`, `"repo.git/info/refs"`, `"repo.git/info/packs"`, `"repo.git/packed-refs"`) or check the basename with `Path::file_name()`.24+25+5. **Symlink handling is inconsistent across `.forge/` readers.** `wiki` skips symlinks explicitly and `collect_artifacts` skips them, but `forge::load` and `releases::load` `fs::read_to_string` the path directly, following symlinks. Today the content must still parse as frontmatter to reach output, so it is not an exploit — it is an invariant gap in the "never follow links out of the repo" rule. Make all four use one `read_repo_file` helper that rejects symlinks (and `..` components).26+27+6. **`--repo-name`/name escaping is fine** (verified: a directory named `"><img src=x onerror=…>` renders escaped in `<title>`/`<h1>`) — recording it here so future refactors keep it.28+29+Acceptance:30+- [ ] `hu13 build .` titles the site with the directory name31+- [ ] gitlink entries do not produce `raw/` files32+- [ ] `inbox list` escapes the id33+- [ ] content-type rule is path-anchored34+- [ ] one symlink-rejecting reader shared by forge/releases/wiki/artifacts
.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md+35 −0
diff --git a/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.mdnew file mode 100644index 0000000..dd0a1e4--- /dev/null+++ b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md@@ -0,0 +1,35 @@1+---2+id: ebb9556e-9e38-4ba0-8081-2b71e45cd51e3+title: repo.git publishes local filesystem path in config4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:47:50.464582699Z10+updated: null11+---12+`dist/repo.git/config` is published and contains the builder's absolute filesystem path.13+14+```15+$ curl -s http://127.0.0.1:39313/repo.git/config16+[core]17+	repositoryformatversion = 018+	filemode = true19+	bare = true20+[remote "origin"]21+	url = ~/src/code/gl0w-w0rld/hu1322+```23+24+`git clone` records `remote.origin.url` pointing at the source path, and every file under `repo.git/` is served verbatim. That leaks the deploy host's directory layout and username (``/home/<user>/src/...``) to the public.25+26+Also published and useless: `description` (the "Unnamed repository" stub) and 14 `hooks/*.sample` files.27+28+Fix, after the clone:29+- `git --git-dir=<dest> config --remove-section remote.origin` (or build the export from the bundle without a named remote and never clone from the path)30+- delete `description`, `hooks/`, `info/exclude` before `update-server-info`31+32+Acceptance:33+- [ ] built `repo.git/config` contains no `remote` section and no absolute path34+- [ ] test: build fixture in a temp dir, assert `config` does not contain the temp dir path35+- [ ] `hooks/` and `description` are absent from the export
.forge/issues/ee5121b8-e3da-4667-88b7-243e11ec27f6/issue.md+30 −0
diff --git a/.forge/issues/ee5121b8-e3da-4667-88b7-243e11ec27f6/issue.md b/.forge/issues/ee5121b8-e3da-4667-88b7-243e11ec27f6/issue.mdnew file mode 100644index 0000000..e31fe6f--- /dev/null+++ b/.forge/issues/ee5121b8-e3da-4667-88b7-243e11ec27f6/issue.md@@ -0,0 +1,30 @@1+---2+id: ee5121b8-e3da-4667-88b7-243e11ec27f63+title: 'serve: git upload-pack has no timeout, no concurrency cap, and fully buffers the pack'4+state: open5+labels:6+- security7+assignees: []8+milestone: null9+created: 2026-10-27T14:53:54.653824678Z10+updated: null11+---12+`/repo.bundle/git-upload-pack` spawns `git upload-pack` per POST with no timeout, no concurrency limit, and buffers the entire pack in RAM (`wait_with_output()` → `Vec<u8>` → `respond`).13+14+Problems:15+- **No timeout**: nothing bounds the child. The 5 s socket read timeout applies to our reads from the client, not to git finishing; a client that dribbles its want-list or stalls mid-pack keeps a thread + a `git` process alive for as long as it likes. The 1 MiB request cap only bounds the *request*.16+- **No concurrency cap**: N parallel clones = N `git upload-pack` processes + N full packs in memory. Two or three concurrent clones of a large repo can OOM the container (default Docker memory limits are generous, Cloudflare-free-boxes are not).17+- **Whole-response buffering**: `respond()` takes `&[u8]`, so the pack is fully materialized before the first byte is written — no streaming, no `Content-Length`-free chunked path available in the current `respond`.18+- Client disconnects are not detected until wait() returns.19+20+Note this is the *smart* path and is serve-only (dumb `/repo.git` is static and unaffected), so a deployment that never advertises `/repo.bundle` still exposes it — the route exists whenever `www/repo.bundle` exists, which `build` always writes.21+22+Fix direction:23+- spawn `upload-pack`, stream stdout to the socket while writing the pack (drop `respond`'s buffering for this route), kill the child on write error24+- enforce a wall-clock timeout (e.g. 60 s) and a concurrency semaphore; 503 when full25+- consider gating the route behind a flag (it is a convenience, not the canonical clone path since #54)26+27+Acceptance:28+- [ ] a stalled client cannot keep a git process alive past the timeout29+- [ ] concurrent clones are capped and the cap is documented30+- [ ] memory use does not scale with pack size for a single clone
.forge/issues/f06329e9-7e81-41f8-acf2-a48baf70cdcd/issue.md+36 −0
diff --git a/.forge/issues/f06329e9-7e81-41f8-acf2-a48baf70cdcd/issue.md b/.forge/issues/f06329e9-7e81-41f8-acf2-a48baf70cdcd/issue.mdnew file mode 100644index 0000000..5d9e411--- /dev/null+++ b/.forge/issues/f06329e9-7e81-41f8-acf2-a48baf70cdcd/issue.md@@ -0,0 +1,36 @@1+---2+id: f06329e9-7e81-41f8-acf2-a48baf70cdcd3+title: 'build aborts (allocator) on large blobs: every file is read fully into memory'4+state: open5+labels:6+- robustness7+assignees: []8+milestone: null9+created: 2026-10-27T14:54:10.641778270Z10+updated: null11+---12+`build` reads every blob fully into memory (`gitx::show` → `run_git` → `Vec<u8>`), regardless of size. `PREVIEW_MAX_BYTES` (256 KiB) only decides whether the *page* renders a preview — the bytes were already in RAM.13+14+Verified:15+16+```17+$ head -c 400000000 /dev/zero > big.bin && git add -A && git commit -m big18+$ ( ulimit -v 900000; hu13 build . -o out )19+memory allocation of 400000000 bytes failed20+Aborted (core dumped)          # exit 13421+```22+23+The failure is a raw allocator abort (SIGABRT), not a clean `hu13: ...` error, and in `entrypoint.sh` this means the container crash-loops before serve starts.24+25+Real repos carry large binaries (videos, datasets, vendored archives) and a PR can add one deliberately. There is no `--max-blob` and no streaming path.26+27+Fix direction:28+- `git cat-file --batch` with a size probe (`git cat-file -s <rev>:<path>` first) so blobs above a threshold are never read; write a placeholder page and skip/stream the `raw/` copy29+- or stream `git show` straight into the `raw/` file and only keep the first 256 KiB for preview/binary sniff30+- make the threshold configurable (`--max-blob`, `HU13_MAX_BLOB`) with a documented default (say 10 MiB) and a clear warning line when a file is skipped31+- ensure the failure mode is a clean error, not an allocator abort32+33+Acceptance:34+- [ ] a repo with a 500 MB blob builds (skips it, warns) instead of aborting35+- [ ] peak RSS stays bounded by the threshold, not by the largest blob36+- [ ] `raw/` either contains the full file (streamed) or is documented as skipped
.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md+28 −0
diff --git a/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.mdnew file mode 100644index 0000000..d4780a9--- /dev/null+++ b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md@@ -0,0 +1,28 @@1+---2+id: f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b3+title: Export duplicates the git runner, shares the 10s timeout, and round-trips paths through UTF-84+state: open5+labels:6+- refactor7+assignees: []8+milestone: null9+created: 2026-10-27T14:47:57.173716686Z10+updated: null11+---12+`export_dumb_repo` re-implements the git subprocess plumbing (~75 lines) instead of reusing `run_git`, and runs the export under the same 10 s `GIT_TIMEOUT`.13+14+Problems:15+16+1. **Second full traversal under a 10 s cap.** `snapshot()` already reads the whole history for `git bundle create` under `GIT_TIMEOUT`. The export is a *second* full pass with the same 10 s budget, so a repo whose bundle takes ~7 s now exceeds the cap in the export step and fails the build with `GitError::Timeout("git clone/update-server-info")` — a regression for large repos that deployed fine before #54.17+2. **Duplicated runner.** `run_plain_git` copies `run_git`'s spawn / stdout-stderr reader threads / poll loop / kill-on-timeout. Every future fix must be made twice; the two already differ in error text and in the `--git-dir` handling.18+3. **Lossy path round-trip.** `canonicalize()` -> `to_string_lossy()` -> `OsString` converts non-UTF-8 paths into replacement characters, so the clone target can be a *different* path than intended.19+20+Fix:21+- fold the runner into one helper that takes an optional cwd (`git -C <cwd>` vs plain `git`), keeping `GIT_TIMEOUT` and the kill/reader-thread behaviour identical22+- pass `&OsStr`/`Path` straight through (no string conversion)23+- once #55/#56 are fixed (export from the existing bundle) the marginal cost is small; still, give the export its own, larger budget rather than sharing 10 s with a full-history read24+25+Acceptance:26+- [ ] one subprocess helper; `run_plain_git` gone27+- [ ] no `to_string_lossy` on paths handed to git28+- [ ] timeout is not silently doubled against the same cap (documented constant or separate budget)
.forge/issues/f4e0b79d-0312-4d4d-b014-84337f4ef3db/issue.md+31 −0
diff --git a/.forge/issues/f4e0b79d-0312-4d4d-b014-84337f4ef3db/issue.md b/.forge/issues/f4e0b79d-0312-4d4d-b014-84337f4ef3db/issue.mdnew file mode 100644index 0000000..7c87710--- /dev/null+++ b/.forge/issues/f4e0b79d-0312-4d4d-b014-84337f4ef3db/issue.md@@ -0,0 +1,31 @@1+---2+id: f4e0b79d-0312-4d4d-b014-84337f4ef3db3+title: 'Design foundations: raise the CSS budget (49 bytes free) + spacing/type/color scale'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T15:26:36.434267184Z10+updated: null11+---12+Parent/blocker for the cosmetic pass. `static/style.css` is 15311 / 15360 bytes — **49 bytes of headroom** — and three tests plus `gl0w.md` enforce the cap:13+14+- `tests/polish.rs:127`, `tests/light_theme.rs:220`, `tests/responsive.rs:169` → `len < 15360`15+- `gl0w.md` → "CSS budget: must stay under 15360 bytes (15KB)"16+17+Owner has approved raising the budget as needed for quality. This issue is the mechanical unblock: raise the cap and lay down the scale the other seven skin issues will write against.18+19+Scope:20+1. Raise the budget test to a sane new cap (proposal: **32 KiB** = 32768, still tiny, still zero-JS, still hand-written CSS) and update `gl0w.md` + every test assertion. Keep the `border-radius: 0` rule unless the owner says otherwise.21+2. Add a small, explicit **spacing scale** (`--s1..--s6` ≈ 4/8/12/16/24/32px) and a **type scale** (`--fs-sm..--fs-3xl`) and use them on the *new* rules; do not rewrite everything in one go.22+3. Add the theme tokens the other issues need: `--surface` (card/panel bg, distinct from `--bg`), `--surface-2`, `--fg-dim` for AA-passing muted text, `--accent` alias, `--state-open/merged/closed` colors, and `--radius` (0 today) so a future owner flip to rounded is one line.23+4. Document the scale + token list in a comment block at the top of the file (it is already the de-facto design doc).24+25+Acceptance:26+- [ ] budget constant lives in one place (a test helper or `const`), not three copied literals27+- [ ] `gl0w.md` and the three tests agree with the new cap28+- [ ] new tokens defined for both `:root` (dark) and `@media (prefers-color-scheme:light)`29+- [ ] `cargo test` green with the CSS at its new size30+31+Note: this is the only issue in the batch that others depend on; land it first.