file review #3 issues #77-#96

2b834bagl0w

all commits

Message

file review #3 issues #77-#96

Diff

.forge/issues/11a0d3a2-0082-45aa-b54c-3b574362d86e/issue.md+39 −0
diff --git a/.forge/issues/11a0d3a2-0082-45aa-b54c-3b574362d86e/issue.md b/.forge/issues/11a0d3a2-0082-45aa-b54c-3b574362d86e/issue.mdnew file mode 100644index 0000000..c21af7c--- /dev/null+++ b/.forge/issues/11a0d3a2-0082-45aa-b54c-3b574362d86e/issue.md@@ -0,0 +1,39 @@1+---2+id: 11a0d3a2-0082-45aa-b54c-3b574362d86e3+title: 'build: enforce Cloudflare Pages 20k-file / 25 MiB limits'4+state: open5+labels:6+- area:release7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.360064394Z10+updated: null11+---12+## What13+14+The build can emit artifacts Cloudflare Pages rejects:15+16+- **25 MiB per file**: a 64 MB repo blob was written to `raw/big.bin` silently.17+- **20,000 files per deployment**: `raw/` + `files/` + dir pages scale with repo size; no count check.18+- `repo.bundle` / `repo.tar.gz` can exceed 25 MiB on larger histories.19+20+## Reproduced21+22+```23+dd if=/dev/zero of=big.bin bs=1M count=6424+hu13 build /tmp/bigprobe -o /tmp/bigout25+find /tmp/bigout -type f -size +25M   # raw/big.bin 64 MB26+```27+28+No warning; Pages would fail the deploy with a generic error.29+30+## Impact31+32+The advertised Pages Free target fails late and opaquely for ordinary asset-heavy repos. The current repo fits (1,325 files), but nothing enforces it.33+34+## Fix35+36+- Add a Pages budget check (always-on or `--pages`): every output file ≤25 MiB, output file count ≤20,000; report the offending paths and suggest Git LFS / external assets.37+- Include bundle/tarball sizes.38+- Tests for a >25 MiB fixture and for the file-count boundary (can use a small threshold under `cfg(test)` or a `--max-file-bytes` override).39+- Mention the limits in the deploy wiki.
.forge/issues/124b9097-1a43-4759-892f-031f877fa794/issue.md+31 −0
diff --git a/.forge/issues/124b9097-1a43-4759-892f-031f877fa794/issue.md b/.forge/issues/124b9097-1a43-4759-892f-031f877fa794/issue.mdnew file mode 100644index 0000000..bfd3870--- /dev/null+++ b/.forge/issues/124b9097-1a43-4759-892f-031f877fa794/issue.md@@ -0,0 +1,31 @@1+---2+id: 124b9097-1a43-4759-892f-031f877fa7943+title: CI logs publish absolute builder paths4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.572335287Z10+updated: null11+---12+## What13+14+CI step logs are published verbatim and include absolute builder paths, e.g.:15+16+```17+Checking hu13 v0.1.0 (~/src/code/gl0w-w0rld/hu13)18+Finished `dev` profile ...19+```20+21+## Reproduced22+23+`/ci/index.html` → `02_test.sh.log` pre.24+25+## Impact26+27+On a public deployment this leaks the builder’s home directory/username and local layout. Not a secret, but avoidable.28+29+## Fix30+31+Redact the repo root (and its parent if needed) from captured logs at build time, e.g. replace the canonical repo path with `<repo>` and `$HOME` with `~`. Keep logs otherwise byte-faithful and escaped. Test with a log containing the absolute path.
.forge/issues/149548ff-72f9-48a8-8c43-feb71cbcd889/issue.md+34 −0
diff --git a/.forge/issues/149548ff-72f9-48a8-8c43-feb71cbcd889/issue.md b/.forge/issues/149548ff-72f9-48a8-8c43-feb71cbcd889/issue.mdnew file mode 100644index 0000000..0c97cef--- /dev/null+++ b/.forge/issues/149548ff-72f9-48a8-8c43-feb71cbcd889/issue.md@@ -0,0 +1,34 @@1+---2+id: 149548ff-72f9-48a8-8c43-feb71cbcd8893+title: 'footer: submit link points at POST-only /inbox/issue (405)'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.404416491Z10+updated: null11+---12+## What13+14+The new footer "submit" link (`src/layout.rs:325`) points at `/inbox/issue`, which is a POST-only endpoint.15+16+- `serve`: `GET /inbox/issue` → **405**17+- static hosts/Pages: 404 or a Function rejection18+19+The user-facing form is `/new-issue.html`.20+21+## Reproduced22+23+```24+curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:39320/inbox/issue25+40526+```27+28+## Impact29+30+The only footer CTA is dead on every page.31+32+## Fix33+34+Point the footer link at `/new-issue.html` (or the issues index) and leave `/inbox/issue` as the form action. Add a test for the footer href and a GET of the linked path in `serve`.
.forge/issues/222f483f-c2d5-4e86-aaa0-e79afe87fe9d/issue.md+32 −0
diff --git a/.forge/issues/222f483f-c2d5-4e86-aaa0-e79afe87fe9d/issue.md b/.forge/issues/222f483f-c2d5-4e86-aaa0-e79afe87fe9d/issue.mdnew file mode 100644index 0000000..72337ca--- /dev/null+++ b/.forge/issues/222f483f-c2d5-4e86-aaa0-e79afe87fe9d/issue.md@@ -0,0 +1,32 @@1+---2+id: 222f483f-c2d5-4e86-aaa0-e79afe87fe9d3+title: 'file pages: phantom final line for trailing newline / CRLF'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.473238063Z10+updated: null11+---12+## What13+14+`render_text_pre` (`src/build.rs:1028`) splits on `\n` without dropping the trailing terminator or `\r`.15+16+- a 403-line file ending in `\n` renders **404** numbered lines (last one empty) while the toolbar says “403 lines”17+- CRLF files keep `\r` in the output and also get the phantom line18+19+## Reproduced20+21+```22+wc -l src/highlight.rs                     # 40323+grep -o 'id="L[0-9]*"' dist/files/src/highlight.rs.html | tail -1   # L40424+```25+26+## Impact27+28+Visible off-by-one gutter and anchor; copy-paste on CRLF files carries stray CR.29+30+## Fix31+32+Normalize `\r\n`/`\r` to `\n`, strip one trailing `\n`, then split. Keep the empty-file special case. Test a trailing-newline file and a CRLF file for exact line count and no empty last line.
.forge/issues/3aeb0d21-a690-4369-82a2-cd7e75037ec7/issue.md+31 −0
diff --git a/.forge/issues/3aeb0d21-a690-4369-82a2-cd7e75037ec7/issue.md b/.forge/issues/3aeb0d21-a690-4369-82a2-cd7e75037ec7/issue.mdnew file mode 100644index 0000000..7752354--- /dev/null+++ b/.forge/issues/3aeb0d21-a690-4369-82a2-cd7e75037ec7/issue.md@@ -0,0 +1,31 @@1+---2+id: 3aeb0d21-a690-4369-82a2-cd7e75037ec73+title: 'diff: added lines starting +++/--- are misclassified as file headers'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.458393633Z10+updated: null11+---12+## What13+14+Diff lines are classified purely by prefix:15+16+- added line whose **content** starts with `+++` → rendered `++++ …` and treated as a file header17+- added line whose content starts with `---` → `+--- …` (this one is counted, but a removed line `---- …` is misclassified)18+19+`src/layout.rs:109-127`: `line.starts_with("+++")` / `starts_with("---")` before the add/remove branches.20+21+## Reproduced22+23+Append four lines to a tracked file: `+++ not a header`, `--- not a header`, `@@ not a hunk`, `prefix+++line`. The commit page reports **+3** (4 actual added) and the `++++ not a header` row is styled as a file header; subsequent new-side line numbers are off by one.24+25+## Impact26+27+Wrong diffstat and line numbers; a reviewer can be misled about what changed (content can hide from the count).28+29+## Fix30+31+Use diff position: `+++`/`---` are headers only before the first `@@` hunk (and `+++` after `---`); otherwise classify by the leading marker. Or require the `a/`/`b/` prefix for headers. Add a fixture test for content that starts with `+++`/`---`/`@@`.
.forge/issues/631e14a9-8678-4817-a87b-c6c0d4e76bbd/issue.md+39 −0
diff --git a/.forge/issues/631e14a9-8678-4817-a87b-c6c0d4e76bbd/issue.md b/.forge/issues/631e14a9-8678-4817-a87b-c6c0d4e76bbd/issue.mdnew file mode 100644index 0000000..2efa18e--- /dev/null+++ b/.forge/issues/631e14a9-8678-4817-a87b-c6c0d4e76bbd/issue.md@@ -0,0 +1,39 @@1+---2+id: 631e14a9-8678-4817-a87b-c6c0d4e76bbd3+title: 'serve: static GET/HEAD buffers whole file in RAM (OOM on large raw files)'4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.347086939Z10+updated: null11+---12+## What13+14+Static file responses buffer the whole file into memory before sending, for **GET and HEAD**:15+16+- `src/serve.rs:695` — `std::fs::read(&dest)` happens before the method check.17+18+Raw blobs are unbounded (build streams any size to `raw/`), so `/raw/big.bin` allocates the full size per request. With `MAX_CONNS=64`, a handful of large files can OOM the process.19+20+## Reproduced21+22+64 MB blob, `VmHWM` before/after one request:23+24+```25+VmHWM:  70,540 kB26+```27+28+(process baseline ~5 MB). HEAD performs the same read.29+30+## Impact31+32+Remote OOM/DoS on `hu13 serve`; the streaming path already exists for git packs (`respond_file`) but is not used for static assets.33+34+## Fix35+36+- Use `respond_file` (or equivalent streaming with `Content-Length` from `stat`) for static files.37+- HEAD: stat only, send headers, no body.38+- Keep the method check before opening/reading the file.39+- Test: large raw file, assert a bounded read/streamed response and HEAD without buffering.
.forge/issues/6a6c2596-f17d-4881-8c95-b1c79ba458cd/issue.md+31 −0
diff --git a/.forge/issues/6a6c2596-f17d-4881-8c95-b1c79ba458cd/issue.md b/.forge/issues/6a6c2596-f17d-4881-8c95-b1c79ba458cd/issue.mdnew file mode 100644index 0000000..1ff9252--- /dev/null+++ b/.forge/issues/6a6c2596-f17d-4881-8c95-b1c79ba458cd/issue.md@@ -0,0 +1,31 @@1+---2+id: 6a6c2596-f17d-4881-8c95-b1c79ba458cd3+title: 'mobile: PR head→base breaks inside branch names'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.531241904Z10+updated: null11+---12+## What13+14+Mobile PR cards allow anywhere-wrapping in the `head→base` cell, so a branch pair breaks inside a word:15+16+```17+m6/submit-buttons→mast18+er19+```20+21+## Reproduced22+23+`/pulls/index.html` at 390 px.24+25+## Impact26+27+Looks broken and can be misread; branch names are tokens.28+29+## Fix30+31+Stack head/base on narrow screens (two lines with the arrow), or keep one line with `white-space:nowrap` inside a scrollable cell. Never break inside a branch name. Add a responsive assertion/screenshot check.
.forge/issues/781103b1-9027-47f7-9a15-49b18b8e825f/issue.md+22 −0
diff --git a/.forge/issues/781103b1-9027-47f7-9a15-49b18b8e825f/issue.md b/.forge/issues/781103b1-9027-47f7-9a15-49b18b8e825f/issue.mdnew file mode 100644index 0000000..9a1c13b--- /dev/null+++ b/.forge/issues/781103b1-9027-47f7-9a15-49b18b8e825f/issue.md@@ -0,0 +1,22 @@1+---2+id: 781103b1-9027-47f7-9a15-49b18b8e825f3+title: 'polish: dir: src H1; relative Atom feed links'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.604351236Z10+updated: null11+---12+## What13+14+Two small leftovers from the cosmetic review:15+16+- Directory pages still show a debug-ish H1: `dir: src` (`src/build.rs:1311`). It should be just `src` (or `src/` with breadcrumb context).17+- `feed.atom` entry links are relative (`<link href="/commit/..."/>`). Atom consumers expect absolute IRIs; relative resolution is inconsistent across readers.18+19+## Fix20+21+- Render the directory name without the `dir:` prefix.22+- Make feed links absolute when `--public-origin` is set; relative fallback otherwise. Keep escaping.
.forge/issues/7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d/issue.md+41 −0
diff --git a/.forge/issues/7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d/issue.md b/.forge/issues/7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d/issue.mdnew file mode 100644index 0000000..efaeef8--- /dev/null+++ b/.forge/issues/7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d/issue.md@@ -0,0 +1,41 @@1+---2+id: 7a84c65c-d72c-46e0-ab96-2c43bdeb7b0d3+title: 'build: -o guard bypass via .. / symlink deletes parent or repo (data loss)'4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.294202432Z10+updated: null11+---12+## What13+14+`prepare_out` protects the repo with a **lexical** path check:15+16+- `src/build.rs:1144` — `absolute(out)` only joins cwd (`.`/`..` are left in).17+- guard: `out_abs == repo_abs || repo_abs.starts_with(&out_abs)`.18+19+`Path::starts_with` normalizes `.` but **not `..`**, so `<repo>/..` passes the guard and `remove_dir_all` resolves through `..` and deletes the parent directory.20+21+## Reproduced22+23+```24+mkdir -p /tmp/prep/repo && cd /tmp/prep/repo && git init -b main25+echo keep > README.md && git add -A && git commit -m one26+echo CANARY > /tmp/prep/keep.txt27+hu13 build . -o ..28+```29+30+Observed: `/tmp/prep/keep.txt`, the repo's `.git` and all sources were deleted; `repo/` was recreated empty and the build failed with `Unable to read current working directory`. A symlinked ancestor (`-o /tmp/alias/src` where `alias -> repo`) deletes repo contents the same way.31+32+## Impact33+34+Data loss: a plausible `-o ..` typo or a bad build command wipes the checkout and its parent. The #67 guard explicitly claims to refuse ancestors.35+36+## Fix37+38+- Lexically normalize `out` (resolve `.`/`..`) and canonicalize the deepest existing ancestor before any comparison.39+- Compare resolved paths; refuse when resolved `out` equals the repo or contains it.40+- Also refuse an in-repo `out` that contains tracked files (`src/`, `.forge/`) while keeping `dist/` legal; or require it to be empty/gitignored.41+- Tests: absolute parent, relative `..`, `../..`, symlinked ancestor; assert a canary outside the output survives.
.forge/issues/86638bd1-4ac6-4330-9f96-e48b1a46492f/issue.md+33 −0
diff --git a/.forge/issues/86638bd1-4ac6-4330-9f96-e48b1a46492f/issue.md b/.forge/issues/86638bd1-4ac6-4330-9f96-e48b1a46492f/issue.mdnew file mode 100644index 0000000..ea3e1b2--- /dev/null+++ b/.forge/issues/86638bd1-4ac6-4330-9f96-e48b1a46492f/issue.md@@ -0,0 +1,33 @@1+---2+id: 86638bd1-4ac6-4330-9f96-e48b1a46492f3+title: 'build: bound and validate timestamps (hostile dates panic or normalize)'4+state: open5+labels:6+- kind:security7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.374686455Z10+updated: null11+---12+## What13+14+`parse_ts_secs` (`src/build.rs:525`) accepts arbitrary years and only checks `1..=31` for the day.15+16+- `created: 100000000000000000-01-01T00:00:00Z` → panic at `src/build.rs:588` (`attempt to multiply with overflow`) in debug; release silently wraps.17+- `created: 2026-02-31T00:00:00Z` → the page's `time title` says `2026-03-03 00:00 UTC` (civil-days normalization) while `datetime` keeps `2026-02-31`.18+- Negative extremes (`-9223372036854775808-…`) can overflow in `y2 - 399`.19+20+## Reproduced21+22+An issue file with the hostile `created` values; `hu13 build` exits 101 in debug.23+24+## Impact25+26+A repo-controlled `.forge` timestamp crashes the build (DoS) or renders a date that was never written. Users reviewing pages see contradictory dates.27+28+## Fix29+30+- Bound the year (e.g. 1..=9999) before doing date math.31+- Validate day against month/leap year (or use a checked calendar conversion and reject overflow).32+- Use checked arithmetic or early bounds so no input can panic.33+- Tests: huge positive/negative year, Feb 31, Apr 31, Feb 29 non-leap/leap, valid git `YYYY-MM-DD HH:MM:SS ±HHMM`.
.forge/issues/90121d61-86ed-48ff-8c57-b04fb8a3ce5d/issue.md+35 −0
diff --git a/.forge/issues/90121d61-86ed-48ff-8c57-b04fb8a3ce5d/issue.md b/.forge/issues/90121d61-86ed-48ff-8c57-b04fb8a3ce5d/issue.mdnew file mode 100644index 0000000..3576d1e--- /dev/null+++ b/.forge/issues/90121d61-86ed-48ff-8c57-b04fb8a3ce5d/issue.md@@ -0,0 +1,35 @@1+---2+id: 90121d61-86ed-48ff-8c57-b04fb8a3ce5d3+title: 'Cloudflare: implement Pages Functions + R2 inbox (current deploy has no inbox)'4+state: open5+labels:6+- area:inbox7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.330249308Z10+updated: null11+---12+## What13+14+The Cloudflare target is still incomplete: the repo has no `functions/`, no R2 code and no Pages deploy path. `dist/_headers` is emitted, but on Pages the forms POST to `/inbox/issue` and `/inbox/pr-prompt` with no Function behind them, so the inbox is dead. Docker/`hu13 serve` is the only complete target today.15+16+## Required parity17+18+Implement Pages Functions for both endpoints with the existing inbox contract:19+20+- urlencoded only (`application/x-www-form-urlencoded`; reject multipart/JSON), 32 KiB whole-request cap, strict UTF-8, NUL → 40021+- strip controls; title ≤300, body/prompt ≤20000; license required; honeypot fake-200 without storing22+- same-origin Origin gate, ~500 ms fixed delay, no echo, `X-Content-Type-Options: nosniff` + `Cache-Control: no-store`23+- ack uuid exactly once24+- R2 object per entry with the schema: `type/title/body/handle/contact/license_version/received_at/salted_ip_hash`25+- per-IP rate limit and global quota (or document a verified Cloudflare-native equivalent, e.g. WAF/rate rules + R2 quota check) with 429/50326+27+## Deliverables28+29+- `functions/inbox/issue.js` + `functions/inbox/pr-prompt.js` (or one shared handler)30+- R2 binding docs (`INBOX`, `INBOX_SALT` secret) and `wrangler`/Pages deploy recipe; `_routes.json` limited to `/inbox/*`31+- Node tests for validation/ack/R2 writes using a fake bucket (no network)32+- update `.forge/wiki/deploy` to describe the Pages path honestly (what works, what needs dashboard config)33+- clone URL: document `--public-origin` so the user-facing `git clone <origin>/repo.git` is real on Pages34+35+Acceptance: local Node tests pass; the function code is deployable with a documented binding; docs no longer imply the static-only deploy has a working inbox.
.forge/issues/ac166f8e-1ace-4c92-adc3-573aae822ae2/issue.md+25 −0
diff --git a/.forge/issues/ac166f8e-1ace-4c92-adc3-573aae822ae2/issue.md b/.forge/issues/ac166f8e-1ace-4c92-adc3-573aae822ae2/issue.mdnew file mode 100644index 0000000..49d6dab--- /dev/null+++ b/.forge/issues/ac166f8e-1ace-4c92-adc3-573aae822ae2/issue.md@@ -0,0 +1,25 @@1+---2+id: ac166f8e-1ace-4c92-adc3-573aae822ae23+title: raw policy differs between serve and _headers4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.552802224Z10+updated: null11+---12+## What13+14+`serve` and Pages disagree on `/raw/*` text files:15+16+- `serve`: text extensions → `text/plain; charset=utf-8`, `inline`17+- `_headers` (`src/build.rs:1097`): every `/raw/*` gets `Content-Disposition: attachment`, so text downloads18+19+## Impact20+21+Same URL behaves differently across deployment shapes (the core single-shape goal). Not a security hole, but review/clone/download UX differs and the docs are ambiguous.22+23+## Fix24+25+Pick one policy. Recommended: **attachment for all raw files** on both (safer, simpler), or encode the exact text-vs-active policy in `_headers` per extension. Update the deploy wiki and tests so both paths agree.
.forge/issues/b34ceec4-fcb1-4990-ac52-eaff51b04907/issue.md+31 −0
diff --git a/.forge/issues/b34ceec4-fcb1-4990-ac52-eaff51b04907/issue.md b/.forge/issues/b34ceec4-fcb1-4990-ac52-eaff51b04907/issue.mdnew file mode 100644index 0000000..0206d37--- /dev/null+++ b/.forge/issues/b34ceec4-fcb1-4990-ac52-eaff51b04907/issue.md@@ -0,0 +1,31 @@1+---2+id: b34ceec4-fcb1-4990-ac52-eaff51b049073+title: file paths in hrefs are not percent-encoded (# ? % break navigation)4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.487219788Z10+updated: null11+---12+## What13+14+`href_esc` (`src/build.rs:407`) is only HTML escaping; file path segments are never percent-encoded. Git allows `#`, `?`, `%`, spaces and quotes in filenames.15+16+- `a#b.txt` → `/files/a#b.txt.html` requests `/files/a` → 40417+- `q?x.txt` → `/files/q?x.txt.html` requests `/files/q` → 40418+- spaces happen to work because browsers encode them; `%` can be rejected as a bad escape by `serve`19+- raw links, breadcrumb links and directory rows share the helper20+21+## Reproduced22+23+A fixture repo with `a#b.txt`, `a b.txt`, `q?x.txt`; navigation 404s for `#`/`?` while `/files/a%23b.txt.html` returns 200.24+25+## Impact26+27+A repo-controlled filename can make a file unreachable from the UI (or hide it from review); not XSS because `esc` still runs.28+29+## Fix30+31+Add a path-segment percent-encoder (`[A-Za-z0-9-._~]` plus `/`; everything else `%XX` UTF-8) and use it for hrefs only. Keep displayed text escaped. Test `#`, `?`, `%`, space, non-ASCII, quote.
.forge/issues/b83b91fd-f161-4b1f-af8c-a735351379a9/issue.md+37 −0
diff --git a/.forge/issues/b83b91fd-f161-4b1f-af8c-a735351379a9/issue.md b/.forge/issues/b83b91fd-f161-4b1f-af8c-a735351379a9/issue.mdnew file mode 100644index 0000000..63c98ef--- /dev/null+++ b/.forge/issues/b83b91fd-f161-4b1f-af8c-a735351379a9/issue.md@@ -0,0 +1,37 @@1+---2+id: b83b91fd-f161-4b1f-af8c-a735351379a93+title: '404: emit branded 404.html and serve it on unknown paths'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.418458801Z10+updated: null11+---12+## What13+14+`dist/` has no `404.html`, and `serve` falls back to a bare stub (`src/serve.rs:418`) that differs from built pages:15+16+- no footer/brand links17+- stale nav (shows `tasks` even when the built nav hides it)18+- only `not found` / `Nothing here.`19+20+On Cloudflare Pages and nginx, unknown paths show the host's default 404 instead of the site.21+22+## Reproduced23+24+```25+ls /tmp/rev/404.html          # missing26+curl -s http://127.0.0.1:39320/nope   # stub page27+```28+29+## Impact30+31+Breaks the "one shape" requirement and looks broken at the most visible failure state.32+33+## Fix34+35+- Emit a branded `404.html` through the normal layout (nav/footer/provenance; empty-ish body).36+- `serve`: for unknown paths, serve `404.html` with status 404 if present; keep the stub only as a fallback.37+- Ensure `GET /` and real files still 200; add tests for status + footer + nav consistency.
.forge/issues/bd94a078-ded3-470f-b5e8-ca01e4318be5/issue.md+32 −0
diff --git a/.forge/issues/bd94a078-ded3-470f-b5e8-ca01e4318be5/issue.md b/.forge/issues/bd94a078-ded3-470f-b5e8-ca01e4318be5/issue.mdnew file mode 100644index 0000000..c8982b6--- /dev/null+++ b/.forge/issues/bd94a078-ded3-470f-b5e8-ca01e4318be5/issue.md@@ -0,0 +1,32 @@1+---2+id: bd94a078-ded3-470f-b5e8-ca01e4318be53+title: 'diff: block rows separated by newlines render a blank line between every row'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.438675160Z10+updated: null11+---12+## What13+14+`diff_html` emits block-level rows separated by literal `\n` inside `<pre>`:15+16+```17+</span>\n<span class="df-line" ...>18+```19+20+`pre` preserves whitespace, so each newline creates an extra empty line box between rows. Every diff row is followed by a blank row — the diff looks striped and is twice as tall.21+22+## Reproduced23+24+`/commit/<sha>.html` and PR Patch sections, desktop and mobile. HTML confirms `</span>\n<span class="df-line"`; screenshot shows green/pink bands with white gaps equal to line height.25+26+## Impact27+28+Poor readability of the core review surface; mobile diffs get very long.29+30+## Fix31+32+Emit no newline between block rows (or make rows inline-block and keep newlines; but then full-width backgrounds need attention). Keep the newlines out of the `<pre>` and let each row be a block. Add a test asserting no `</span>\n<span class="df-line"` and a visual check.
.forge/issues/c8829454-03db-4c42-bb71-3c47adfadbfe/issue.md+41 −0
diff --git a/.forge/issues/c8829454-03db-4c42-bb71-3c47adfadbfe/issue.md b/.forge/issues/c8829454-03db-4c42-bb71-3c47adfadbfe/issue.mdnew file mode 100644index 0000000..1f1e3be--- /dev/null+++ b/.forge/issues/c8829454-03db-4c42-bb71-3c47adfadbfe/issue.md@@ -0,0 +1,41 @@1+---2+id: c8829454-03db-4c42-bb71-3c47adfadbfe3+title: 'highlight: O(n²) per-char suffix allocation makes builds take minutes'4+state: open5+labels:6+- kind:build7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.311886997Z10+updated: null11+---12+## What13+14+`src/highlight.rs:252` rebuilds the rest of the line for **every character**:15+16+```rust17+let rest: String = chars[i..].iter().collect();18+```19+20+That is O(n²) allocation/copy per line, and `highlight_lines` runs for every text file up to `MAX_HIGHLIGHT_BYTES = 512 KiB`.21+22+## Reproduced23+24+Repo with one 64 KB single-line JS file (`'var x=1;' * 8000`):25+26+```27+hu13 build /tmp/hlprobe -o /tmp/hlout28+real 0m27.5s29+```30+31+Quadratic scaling means a 512 KiB minified file can take ~30 minutes; several such files stall a build.32+33+## Impact34+35+Build-time DoS / unacceptable builds on ordinary minified assets. `MAX_HIGHLIGHT_BYTES` does not protect because the cost is per line.36+37+## Fix38+39+- Scan on byte/char indices of `&str` (or one `Vec<char>` per line) without building suffix `String`s.40+- Consider capping a single line (e.g. 8 KiB) to plain text; keep whole-file cap.41+- Add a timing/scaling unit test with a 64 KB single-line fixture (coarse bound, not wall-clock tight).
.forge/issues/cd6f8d85-f82c-4512-a6b8-e0b6be1647da/issue.md+35 −0
diff --git a/.forge/issues/cd6f8d85-f82c-4512-a6b8-e0b6be1647da/issue.md b/.forge/issues/cd6f8d85-f82c-4512-a6b8-e0b6be1647da/issue.mdnew file mode 100644index 0000000..9e6a0d6--- /dev/null+++ b/.forge/issues/cd6f8d85-f82c-4512-a6b8-e0b6be1647da/issue.md@@ -0,0 +1,35 @@1+---2+id: cd6f8d85-f82c-4512-a6b8-e0b6be1647da3+title: 'issues/pulls: generated HTML in title= corrupts every row'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.388211661Z10+updated: null11+---12+## What13+14+The issues and pulls indexes pass generated HTML into a `title=` attribute:15+16+- `src/forge.rs:1146` — `title="{state}"`, `title="{created}"`17+- `src/forge.rs:1215` — same for pulls18+19+`state` is `<span class="pill ...">closed</span>` and `created` is `<time ...>1h ago</time>`, so the inner quotes terminate the attribute. Every row renders literal `closed">` / `1h ago">1h ago`, and mobile cards show the same noise. The malformed markup also includes attributes that were meant for inner elements on the `<td>`.20+21+This is not XSS today (the inner text is escaped, state classes are fixed), but it is fragile: any future user-controlled content in those titles becomes an injection point.22+23+## Reproduced24+25+`/issues/index.html` and `/pulls/index.html`:26+27+```28+<td ... title="<span class="pill state-closed">closed</span>"><span ...29+```30+31+Visible: `closed">  [closed]` and `1h ago">1h ago`.32+33+## Fix34+35+Keep plain-text and HTML variants per cell: escaped plain `issue.state` / `relative age` in `title=`, HTML pill/time in the body. Add a test that the index contains no `title="<` and no `">` artifacts.
.forge/issues/d4ad245c-be33-43a5-a080-af3cdb89c9b4/issue.md+29 −0
diff --git a/.forge/issues/d4ad245c-be33-43a5-a080-af3cdb89c9b4/issue.md b/.forge/issues/d4ad245c-be33-43a5-a080-af3cdb89c9b4/issue.mdnew file mode 100644index 0000000..8ebf7c3--- /dev/null+++ b/.forge/issues/d4ad245c-be33-43a5-a080-af3cdb89c9b4/issue.md@@ -0,0 +1,29 @@1+---2+id: d4ad245c-be33-43a5-a080-af3cdb89c9b43+title: 'responsive: nav clipped at 768–1099; separator dots wrap onto line starts'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.516630583Z10+updated: null11+---12+## What13+14+- At **768–1099 px** the one-row nav is horizontally scrollable but has no fade/affordance; at 768 px the last items are clipped mid-word (“… releases · w”) in screenshots.15+- Below 768 px the wrap works, but literal ` · ` separators stay outside the links: a line can end with `·` and the next starts with `· agents` at 320/390.16+17+## Reproduced18+19+Screenshots at 320/390/768: wrapped nav has leading/trailing bullets; 768 shows clipped tabs.20+21+## Impact22+23+Navigation looks broken at the tablet breakpoint; separators look like stray punctuation on phones.24+25+## Fix26+27+- Render separators as `<span class="nav-sep" aria-hidden="true">·</span>` and hide them below 768 (use flex `column-gap`).28+- Add a fade or wrap until the nav fits (or move the wrap breakpoint to ~1024); assert 320/390/768 all reach every tab visibly without mid-word clipping.29+- Keep 40px targets and `aria-current`.
.forge/issues/df7c147b-b62e-4201-b7cd-dab4180b0e96/issue.md+31 −0
diff --git a/.forge/issues/df7c147b-b62e-4201-b7cd-dab4180b0e96/issue.md b/.forge/issues/df7c147b-b62e-4201-b7cd-dab4180b0e96/issue.mdnew file mode 100644index 0000000..7a7c699--- /dev/null+++ b/.forge/issues/df7c147b-b62e-4201-b7cd-dab4180b0e96/issue.md@@ -0,0 +1,31 @@1+---2+id: df7c147b-b62e-4201-b7cd-dab4180b0e963+title: 'light theme: diff text fails AA; form fields look disabled (border 1.2:1)'4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.500908552Z10+updated: null11+---12+## What13+14+Two light-theme contrast failures were missed by `tests/theme_contrast.rs`:15+16+1. **Diff text on tinted backgrounds**: `--diff-add` on `--diff-add-bg` = **4.44:1**; `--diff-del` on `--diff-del-bg` = **4.11:1** (AA needs 4.5).17+2. **Form fields look disabled**: `input/textarea` use `background:var(--bg-alt)` with `border:1px solid var(--border)`; light border vs field = **~1.21:1** (UI boundaries need 3:1). The fill vs page is also ~1.07:1, so the field edge is nearly invisible.18+19+## Reproduced20+21+Computed from the parsed CSS variables (dark passes); screenshots show the light inputs as flat grey rectangles with no visible border.22+23+## Impact24+25+A11y failure and a poor affordance — users cannot tell the fields are editable on light mode.26+27+## Fix28+29+- Darken light `--diff-add`/`--diff-del` until each passes 4.5 on its background; add those pairs to the contrast test.30+- Give form controls a ≥3:1 boundary (darker border or white field on the page background) in both themes.31+- Optionally add a test for control-border contrast.
.forge/issues/eaaa6c17-40a0-4b35-b208-f5f8bb3eacc9/issue.md+22 −0
diff --git a/.forge/issues/eaaa6c17-40a0-4b35-b208-f5f8bb3eacc9/issue.md b/.forge/issues/eaaa6c17-40a0-4b35-b208-f5f8bb3eacc9/issue.mdnew file mode 100644index 0000000..9d4c6e1--- /dev/null+++ b/.forge/issues/eaaa6c17-40a0-4b35-b208-f5f8bb3eacc9/issue.md@@ -0,0 +1,22 @@1+---2+id: eaaa6c17-40a0-4b35-b208-f5f8bb3eacc93+title: og:image is relative (needs public origin for scrapers)4+state: open5+labels:6+- ui7+assignees: []8+milestone: null9+created: 2026-10-27T17:24:52.591244847Z10+updated: null11+---12+## What13+14+`og:image` is emitted as a relative URL (`/og.png`) in `src/layout.rs`.15+16+## Impact17+18+Many social scrapers require an absolute URL; without `--public-origin` the preview image may be ignored. With a Pages project origin, it should be absolute.19+20+## Fix21+22+When `public_origin` is set, emit `<public_origin>/og.png` and make `og:url` absolute too; otherwise keep the relative fallback and document the limitation. Escape/validate the origin as today.