review #3: harden build/serve, fix render regressions, add Pages inbox (#77-#96)
Message
review #3: harden build/serve, fix render regressions, add Pages inbox (#77-#96)
Diff
.forge/ci/steps/04_pages_inbox.sh+8 −0
--- /dev/null+++ b/.forge/ci/steps/04_pages_inbox.sh@@ -0,0 +1,8 @@1+#!/bin/sh2+# Cloudflare Pages inbox parity (f0rge #79). Skips only when Node is absent.3+set -eu4+if ! command -v node >/dev/null 2>&1; then5+ echo "pages inbox: node not installed; skipping"6+ exit 07+fi8+node --test tests/pages_inbox.test.mjs
.forge/wiki/deploy+5 −0
--- a/.forge/wiki/deploy+++ b/.forge/wiki/deploy@@ -69,6 +69,11 @@ What happens inside (`entrypoint.sh`):6969 `dist/_headers` carries the `/raw/**` download policy for Pages. On7070 nginx, add the equivalent `add_header Content-Disposition attachment`7171 for `/raw/` — committed HTML/SVG must never render from that path.72+- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The73+ Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and74+ secret `INBOX_SALT` implement the same contract. See75+ `docs/cloudflare.md` for the bucket, binding, secret and WAF rate-rule76+ recipe. `dist/_routes.json` limits Functions to `/inbox/*`.7277 7378 - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.7479 If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`
docs/cloudflare.md+95 −0
--- /dev/null+++ b/docs/cloudflare.md@@ -0,0 +1,95 @@1+# Deploy hu13 on Cloudflare Pages (Free)2+3+The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On4+Pages the inbox is implemented by a Pages Function backed by R2; a5+static-only upload has no working inbox.6+7+## 1. Build the site8+9+```sh10+cargo build --release11+./target/release/hu13 build . -o dist \12+ --public-origin https://hu13.example.com13+```14+15+`--public-origin` makes the clone command, `og:image`, Atom links and16+canonical URLs absolute. The build refuses output over Cloudflare's17+limits (20,000 files, 25 MiB/file) and writes `_headers` and18+`_routes.json` into `dist/`.19+20+## 2. Create the R2 bucket and Pages project21+22+```sh23+npx wrangler r2 bucket create hu13-inbox24+npx wrangler pages project create hu1325+```26+27+Bind the bucket to the Pages project as **`INBOX`** (dashboard:28+Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in29+`wrangler.toml`). Set the required secret:30+31+```sh32+npx wrangler pages secret put INBOX_SALT --project-name hu1333+```34+35+`INBOX_SALT` is a random string (for example `openssl rand -hex 32`).36+The function hashes `v1|<ip>|<yyyy-mm-dd>|<salt>` with SHA-256; no37+plaintext IP is stored. Rotating the secret unlinks old hashes.38+39+Optional variables: `INBOX_RATE_PER_HOUR` (default 2),40+`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300),41+`INBOX_MAX_BODY` (20000).42+43+## 3. Deploy44+45+```sh46+npx wrangler pages deploy dist --project-name hu1347+```48+49+Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json`50+limits Functions to `/inbox/*`, so every other request is static.51+Keep `wrangler.toml` at the repo root for the R2 binding.52+53+## What the Function implements54+55+`functions/inbox/[kind].js` serves both mailboxes:56+57+- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`,58+ `body` accepted as an alias)59+- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 40060+- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤10061+- license checkbox required; filled honeypot returns a fake receipt62+ without storing63+- same-origin `Origin`/`Referer` gate; no input is echoed or logged64+- fixed ~500 ms response delay; `Cache-Control: no-store` and65+ `X-Content-Type-Options: nosniff`66+- one R2 object per accepted submission:67+ `<uuid>.json` with `type/title/body/handle/contact/license_version/68+ received_at/salted_ip_hash` (same shape as the Docker inbox)69+70+Rate limits are layered: the Function has a best-effort isolate-local71+bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting72+rule for `/inbox/*` (for example 10 requests/minute per IP) as the73+durable control. The global quota is checked against the R2 bucket74+before storing; R2 list consistency means a burst can briefly exceed it.75+76+## Reading submissions77+78+```sh79+npx wrangler r2 object list hu13-inbox80+npx wrangler r2 object get hu13-inbox/<uuid>.json81+```82+83+`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`;84+it does not read R2. Copy objects down if you want the terminal reader.85+86+## Limits and caveats87+88+- Pages Free: 20,000 files and 25 MiB per file per deployment. The89+ build enforces both; large assets belong outside git or in Git LFS.90+- The function bundle must be deployed with `functions/`; uploading only91+ `dist/` (drag-and-drop or a plain static host) gives a dead inbox.92+- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no93+ smart server. No GitHub dependency.94+- Committed `/raw/*` bytes always download (`Content-Disposition:95+ attachment`) on both `serve` and Pages.
functions/inbox/[kind].js+349 −0
--- /dev/null+++ b/functions/inbox/[kind].js@@ -0,0 +1,349 @@1+// Cloudflare Pages Functions inbox (f0rge #79).2+//3+// One dynamic route serves both mailboxes: /inbox/issue and4+// /inbox/pr-prompt. Contract parity with `hu13 serve`:5+// urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected,6+// controls stripped, title/body caps, license required,7+// same-origin gate, fixed delay, ack uuid, no echo.8+// Storage is one JSON object per submission in the R2 binding INBOX.9+//10+// Bindings (Pages project settings / wrangler.toml):11+// INBOX R2 bucket12+// INBOX_SALT secret (required; SHA-256 salt for IP hashes)13+// Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500).14+15+const MAX_BYTES = 32 * 1024;16+const MAX_TITLE = 300;17+const MAX_BODY = 20000;18+const MAX_HANDLE = 40;19+const MAX_CONTACT = 100;20+const LICENSE_VERSION = "v1";21+const ACK_DELAY_MS = 500;22+23+const encoder = new TextEncoder();24+25+function esc(s) {26+ return String(s).replace(27+ /[&<>"]/g,28+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],29+ );30+}31+32+function page(title, body) {33+ return `<!DOCTYPE html>34+<html lang="en"><head><meta charset="utf-8">35+<meta name="viewport" content="width=device-width, initial-scale=1">36+<meta name="robots" content="noindex">37+<title>${esc(title)}</title></head>38+<body><main><h1>${esc(title)}</h1>39+${body}40+<p><a href="/">hu13</a></p></main></body></html>41+`;42+}43+44+function answer(status, body, extra = {}, delay = true) {45+ const headers = {46+ "content-type": "text/html; charset=utf-8",47+ "x-content-type-options": "nosniff",48+ "cache-control": "no-store",49+ ...extra,50+ };51+ const response = () => new Response(body, { status, headers });52+ if (!delay) return response();53+ return new Promise((resolve) =>54+ setTimeout(() => resolve(response()), ACK_DELAY_MS),55+ );56+}57+58+function errorPage(status, detail, extra = {}) {59+ const title = status === 405 ? "method not allowed" : status === 42960+ ? "too many requests"61+ : status === 50362+ ? "unavailable"63+ : status === 41364+ ? "too large"65+ : "bad request";66+ return answer(67+ status,68+ page(title, `<p>${esc(detail)}</p>`),69+ extra,70+ );71+}72+73+function ackPage(uuid) {74+ return page(75+ "received",76+ `<p>ack: <code>${esc(uuid)}</code></p>77+<p>Thank you. This identifier was shown once and is not a lookup URL.</p>`,78+ );79+}80+81+function sleep(ms) {82+ return new Promise((resolve) => setTimeout(resolve, ms));83+}84+85+// Same-origin gate: browsers send Origin/Referer; agents (curl) send86+// neither and pass. `null`/unparsable/mismatched hosts fail closed.87+function originAllowed(request) {88+ let host;89+ try {90+ host = new URL(request.url).host.toLowerCase();91+ } catch {92+ return false;93+ }94+ for (const name of ["origin", "referer"]) {95+ const value = request.headers.get(name);96+ if (value === null) continue;97+ if (!value || value.trim().toLowerCase() === "null") return false;98+ try {99+ if (new URL(value).host.toLowerCase() !== host) return false;100+ } catch {101+ return false;102+ }103+ }104+ return true;105+}106+107+function hexByte(b) {108+ if (b >= 0x30 && b <= 0x39) return b - 0x30;109+ if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10;110+ if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10;111+ return -1;112+}113+114+// Strict percent-decoder matching hu13's `parse_form`: malformed `%`,115+// non-UTF-8, or a bad escape returns null (400) instead of guessing.116+function decodeComponent(input) {117+ const raw = encoder.encode(input);118+ const out = [];119+ let i = 0;120+ while (i < raw.length) {121+ let b = raw[i++];122+ if (b === 0x25) {123+ if (i + 1 >= raw.length) return null;124+ const hi = hexByte(raw[i++]);125+ const lo = hexByte(raw[i++]);126+ if (hi < 0 || lo < 0) return null;127+ b = (hi << 4) | lo;128+ } else if (b === 0x2b) {129+ b = 0x20;130+ }131+ out.push(b);132+ }133+ try {134+ return new TextDecoder("utf-8", { fatal: true }).decode(135+ Uint8Array.from(out),136+ );137+ } catch {138+ return null;139+ }140+}141+142+function parseForm(body) {143+ if (body === "") return [];144+ const pairs = [];145+ for (const part of body.split("&")) {146+ const eq = part.indexOf("=");147+ const key = eq < 0 ? part : part.slice(0, eq);148+ const value = eq < 0 ? "" : part.slice(eq + 1);149+ const k = decodeComponent(key);150+ const v = decodeComponent(value);151+ if (k === null || v === null) return null;152+ pairs.push([k, v]);153+ }154+ return pairs;155+}156+157+function field(pairs, name) {158+ const hit = pairs.find(([k]) => k === name);159+ return hit ? hit[1] : undefined;160+}161+162+function stripControls(s) {163+ return [...s]164+ .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c))165+ .join("");166+}167+168+function licenseChecked(value) {169+ if (value === undefined) return false;170+ return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes(171+ value.trim().toLowerCase(),172+ );173+}174+175+function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) {176+ if (177+ (field(pairs, "website") || "") !== "" ||178+ (field(pairs, "honeypot") || "") !== ""179+ ) {180+ return { kind: "honeypot" };181+ }182+ for (const [k, v] of pairs) {183+ if (k.includes("\0") || v.includes("\0")) return { kind: "reject" };184+ }185+ const rawTitle = field(pairs, "title") || "";186+ const bodyName = kind === "pr-prompt" ? "prompt" : "body";187+ let rawBody = field(pairs, bodyName);188+ if (rawBody === undefined && kind === "pr-prompt") {189+ rawBody = field(pairs, "body");190+ }191+ rawBody = rawBody || "";192+ const title = stripControls(rawTitle);193+ const body = stripControls(rawBody);194+ const handle = stripControls(field(pairs, "handle") || "");195+ const contact = stripControls(field(pairs, "contact") || "");196+ if (title.trim() === "" || body.trim() === "") return { kind: "reject" };197+ if (198+ [...title].length > maxTitle ||199+ [...body].length > maxBody ||200+ [...handle].length > MAX_HANDLE ||201+ [...contact].length > MAX_CONTACT202+ ) {203+ return { kind: "reject" };204+ }205+ if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" };206+ return { kind: "accept", title, body, handle, contact };207+}208+209+async function readCapped(request) {210+ const declared = request.headers.get("content-length");211+ if (declared !== null) {212+ if (!/^\d+$/.test(declared.trim())) return { error: 400 };213+ if (Number(declared) > MAX_BYTES) return { error: 413 };214+ }215+ if (!request.body) return { text: "" };216+ const reader = request.body.getReader();217+ const chunks = [];218+ let total = 0;219+ for (;;) {220+ const { done, value } = await reader.read();221+ if (done) break;222+ total += value.byteLength;223+ if (total > MAX_BYTES) {224+ await reader.cancel();225+ return { error: 413 };226+ }227+ chunks.push(value);228+ }229+ const merged = new Uint8Array(total);230+ let at = 0;231+ for (const chunk of chunks) {232+ merged.set(chunk, at);233+ at += chunk.byteLength;234+ }235+ try {236+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) };237+ } catch {238+ return { error: 400 };239+ }240+}241+242+async function saltedIpHash(ip, salt) {243+ const date = new Date().toISOString().slice(0, 10);244+ const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`);245+ const digest = await crypto.subtle.digest("SHA-256", bytes);246+ return [...new Uint8Array(digest)]247+ .map((b) => b.toString(16).padStart(2, "0"))248+ .join("");249+}250+251+// Best-effort isolate-local limiter. Cloudflare's edge rate-limiting252+// rule for /inbox/* is the durable control; this still stops a single253+// hot isolate from storing unlimited submissions.254+const buckets = new Map();255+function rateAllowed(key, limit) {256+ const now = Date.now();257+ if (buckets.size > 10000) {258+ for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);259+ }260+ let b = buckets.get(key);261+ if (!b || b.reset <= now) {262+ b = { count: 0, reset: now + 3600_000 };263+ buckets.set(key, b);264+ }265+ if (b.count >= limit) return false;266+ b.count += 1;267+ return true;268+}269+270+async function overQuota(bucket, limit) {271+ const listed = await bucket.list({ limit: Math.min(limit, 1000) });272+ return listed.objects.length >= limit;273+}274+275+function nowRfc3339() {276+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");277+}278+279+async function handlePost(context) {280+ const request = context.request;281+ const rawKind = context.params ? context.params.kind : "";282+ const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind;283+ if (kind !== "issue" && kind !== "pr-prompt") {284+ return errorPage(404, "Nothing here.", {}, false);285+ }286+ if (!originAllowed(request)) {287+ return errorPage(403, "Bad origin.");288+ }289+ const ctype = (request.headers.get("content-type") || "").toLowerCase();290+ if (!ctype.startsWith("application/x-www-form-urlencoded")) {291+ return errorPage(400, "This endpoint accepts urlencoded forms only.");292+ }293+ const read = await readCapped(request);294+ if (read.error) {295+ return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input.");296+ }297+ if (read.text.includes("\0")) return errorPage(400, "Bad input.");298+ const pairs = parseForm(read.text);299+ if (pairs === null) return errorPage(400, "Bad input.");300+ const checked = validate(kind, pairs,301+ context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE,302+ context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY);303+ if (checked.kind === "honeypot") {304+ return answer(200, ackPage(crypto.randomUUID()));305+ }306+ if (checked.kind === "reject") return errorPage(400, "Bad input.");307+308+ const env = context.env || {};309+ if (!env.INBOX) return errorPage(503, "Could not store that. Try later.");310+ if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later.");311+ const ip = request.headers.get("cf-connecting-ip") || "unknown";312+ const ipHash = await saltedIpHash(ip, env.INBOX_SALT);313+ const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2;314+ if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down.");315+ const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500;316+ try {317+ if (await overQuota(env.INBOX, quota)) {318+ return errorPage(503, "Inbox full. Try later.");319+ }320+ const uuid = crypto.randomUUID();321+ const doc = {322+ type: kind,323+ title: checked.title,324+ body: checked.body,325+ handle: checked.handle,326+ contact: checked.contact,327+ license_version: LICENSE_VERSION,328+ received_at: nowRfc3339(),329+ salted_ip_hash: ipHash,330+ };331+ await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n");332+ return answer(200, ackPage(uuid));333+ } catch {334+ return errorPage(503, "Could not store that. Try later.");335+ }336+}337+338+export async function onRequestGet() {339+ return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false);340+}341+342+export async function onRequestPost(context) {343+ try {344+ return await handlePost(context);345+ } catch {346+ // Never echo or log submission text.347+ return errorPage(503, "Could not store that. Try later.");348+ }349+}
src/build.rs+285 −19
--- a/src/build.rs+++ b/src/build.rs@@ -353,6 +353,12 @@ pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;353353 /// tried). Prevents a huge README from bloating every page's preview.354354 pub const README_MAX_BYTES: u64 = 1024 * 1024;355355 356+/// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors357+/// every blob, so an ordinary asset-heavy repo can exceed these even358+/// when the build itself succeeds.359+pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;360+pub const PAGES_MAX_FILES: usize = 20_000;361+356362 /// Bytes sniffed for the language-math binary check (first 8KiB).357363 pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;358364 @@ -404,8 +410,25 @@ pub fn is_markdown(path: &str) -> bool {404410 lower.ends_with(".md") || lower.ends_with(".markdown")405411 }406412 413+/// Percent-encode a repo path for a URL, then HTML-escape it. Git414+/// allows `#`, `?`, `%`, spaces and quotes in names; without this the415+/// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal416+/// so file paths remain hierarchical; everything outside the unreserved417+/// set becomes `%XX` of its UTF-8 bytes.418+fn encode_path(path: &str) -> String {419+ let mut out = String::with_capacity(path.len());420+ for &b in path.as_bytes() {421+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {422+ out.push(char::from(b));423+ } else {424+ out.push_str(&format!("%{b:02X}"));425+ }426+ }427+ out428+}429+407430 fn href_esc(path: &str) -> String {408- esc(path)431+ esc(&encode_path(path))409432 }410433 411434 /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars@@ -534,7 +557,20 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {534557 let y: i64 = d.next()?.parse().ok()?;535558 let mo: i64 = d.next()?.parse().ok()?;536559 let da: i64 = d.next()?.parse().ok()?;537- if !(1..=12).contains(&mo) || !(1..=31).contains(&da) {560+ // Keep the civil-date math overflow-free and refuse dates that never561+ // existed (Feb 31 used to normalize to Mar 3; huge years panicked).562+ if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {563+ return None;564+ }565+ let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;566+ let max_day = match mo {567+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,568+ 4 | 6 | 9 | 11 => 30,569+ 2 if leap => 29,570+ 2 => 28,571+ _ => return None,572+ };573+ if !(1..=max_day).contains(&da) {538574 return None;539575 }540576 let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {@@ -550,6 +586,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {550586 };551587 let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;552588 let om: i64 = rest[rest.len() - 2..].parse().ok()?;589+ if oh > 23 || om > 59 {590+ return None;591+ }553592 (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))554593 } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {555594 let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {@@ -559,6 +598,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {559598 };560599 let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;561600 let om: i64 = rest[rest.len() - 2..].parse().ok()?;601+ if oh > 23 || om > 59 {602+ return None;603+ }562604 (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))563605 } else {564606 (rest, 0)@@ -1019,13 +1061,18 @@ pub fn clone_block(10191061 /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container10201062 /// so long lines never break the 360px layout.10211063 fn render_text_pre(display: &str, path: &str) -> String {1064+ // Normalize line endings and drop exactly one trailing terminator:1065+ // `split('\n')` used to turn a final newline into an empty L404 and1066+ // kept `\r` in CRLF files (f0rge #88).1067+ let normalized = display.replace("\r\n", "\n").replace('\r', "\n");1068+ let text = normalized.strip_suffix('\n').unwrap_or(&normalized);10221069 let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");1023- if display.is_empty() {1070+ if text.is_empty() {10241071 out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");10251072 } else {10261073 // Build-time tokens (f0rge #73): unknown/huge files stay plain.1027- let highlighted = crate::highlight::highlight_lines(path, display);1028- for (i, line) in display.split('\n').enumerate() {1074+ let highlighted = crate::highlight::highlight_lines(path, text);1075+ for (i, line) in text.split('\n').enumerate() {10291076 let n = i + 1;10301077 out.push_str("<span class=\"line\" id=\"L");10311078 out.push_str(&n.to_string());@@ -1076,7 +1123,7 @@ fn nav_bar(10761123 tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());10771124 }10781125 tabs.push("<a href=\"/agents.html\">agents</a>".to_string());1079- tabs.join(" · ")1126+ tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")10801127 }10811128 10821129 /// Write `out/style.css` from the embedded static file.@@ -1088,6 +1135,12 @@ fn write_style(out: &Path) -> Result<(), String> {10881135 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`10891136 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).10901137 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`1138+/// Pages Functions routing: only `/inbox/*` runs a Function; every1139+/// other path stays a static asset. Must live in the build output, not1140+/// the repo root (f0rge #79).1141+pub const ROUTES_FILE: &str =1142+ "{\n \"version\": 1,\n \"include\": [\"/inbox/*\"],\n \"exclude\": []\n}\n";1143+10911144 /// Header rules for Cloudflare Pages (and any host that reads `_headers`).10921145 ///10931146 /// `/raw/**` holds committed, untrusted bytes: a repo can contain@@ -1120,11 +1173,12 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {11201173 .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;11211174 std::fs::write(out.join("_headers"), HEADERS_FILE)11221175 .map_err(|e| format!("hu13: write _headers: {e}"))?;1176+ std::fs::write(out.join("_routes.json"), ROUTES_FILE)1177+ .map_err(|e| format!("hu13: write _routes.json: {e}"))?;11231178 Ok(())11241179 }11251180 1126-/// Absolute lexical form of `p` (no symlink resolution beyond what the1127-/// OS already did for existing components).1181+/// Absolute form of `p` (join cwd for relative paths, no symlink work).11281182 fn absolute(p: &Path) -> std::path::PathBuf {11291183 if p.is_absolute() {11301184 p.to_path_buf()@@ -1135,15 +1189,81 @@ fn absolute(p: &Path) -> std::path::PathBuf {11351189 }11361190 }11371191 1192+/// Absolute lexical form of `p` with `.`/`..` collapsed. Does not1193+/// touch the filesystem (unlike canonicalize), so it also works for an1194+/// output path that does not exist yet.1195+fn normalize_lexical(p: &Path) -> std::path::PathBuf {1196+ use std::path::Component;1197+ let mut out = std::path::PathBuf::new();1198+ for c in p.components() {1199+ match c {1200+ Component::CurDir => {}1201+ Component::ParentDir => {1202+ out.pop();1203+ }1204+ Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {1205+ out.push(c.as_os_str());1206+ }1207+ }1208+ }1209+ out1210+}1211+1212+/// Resolve symlinks in the deepest existing ancestor, then append the1213+/// not-yet-created suffix. This catches `-o alias/..` where `alias` is a1214+/// symlink into the repo (f0rge #77).1215+fn resolve_existing(p: &Path) -> std::path::PathBuf {1216+ let abs = normalize_lexical(&absolute(p));1217+ let mut suffix: Vec<std::ffi::OsString> = Vec::new();1218+ let mut cur = abs.as_path();1219+ loop {1220+ if let Ok(mut resolved) = cur.canonicalize() {1221+ for part in suffix.iter().rev() {1222+ resolved.push(part);1223+ }1224+ return normalize_lexical(&resolved);1225+ }1226+ match cur.file_name() {1227+ Some(name) => {1228+ suffix.push(name.to_os_string());1229+ match cur.parent() {1230+ Some(parent) => cur = parent,1231+ None => return abs,1232+ }1233+ }1234+ None => return abs,1235+ }1236+ }1237+}1238+1239+/// `true` when git tracks at least one path under repo-relative `rel`.1240+fn has_tracked_under(repo: &Path, rel: &Path) -> bool {1241+ if rel.as_os_str().is_empty() {1242+ return true;1243+ }1244+ std::process::Command::new("git")1245+ .arg("-C")1246+ .arg(repo)1247+ .arg("ls-files")1248+ .arg("-z")1249+ .arg("--")1250+ .arg(rel)1251+ .output()1252+ .map(|o| o.status.success() && !o.stdout.is_empty())1253+ .unwrap_or(true)1254+}1255+11381256 /// Remove everything already in `out/` before a build (f0rge #67).11391257 ///11401258 /// Without this, pages for deleted issues/files keep being served from a11411259 /// reused output dir. Refuses to delete the repo itself or anything that1142-/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir1143-/// without following it.1260+/// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked1261+/// ancestors before comparing, and refuses an in-repo output that holds1262+/// tracked files (f0rge #77).11441263 fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {1145- let out_abs = absolute(out);1146- let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));1264+ let out_path = normalize_lexical(&absolute(out));1265+ let out_abs = resolve_existing(&out_path);1266+ let repo_abs = resolve_existing(repo);11471267 if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {11481268 return Err(format!(11491269 "hu13: refusing to clean output dir {}: it contains the repo",@@ -1153,25 +1273,114 @@ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {11531273 if out_abs.parent().is_none() {11541274 return Err("hu13: refusing to use the filesystem root as output dir".to_string());11551275 }1156- match std::fs::symlink_metadata(&out_abs) {1276+ if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {1277+ if rel1278+ .components()1279+ .next()1280+ .is_some_and(|c| c.as_os_str() == ".git")1281+ || has_tracked_under(&repo_abs, rel)1282+ {1283+ return Err(format!(1284+ "hu13: refusing to clean in-repo output dir {}: it holds tracked files",1285+ out.display()1286+ ));1287+ }1288+ }1289+ match std::fs::symlink_metadata(&out_path) {11571290 Ok(meta) if meta.file_type().is_symlink() => {1158- std::fs::remove_file(&out_abs)1291+ std::fs::remove_file(&out_path)11591292 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;11601293 }11611294 Ok(meta) if meta.is_dir() => {1162- std::fs::remove_dir_all(&out_abs)1295+ std::fs::remove_dir_all(&out_path)11631296 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;11641297 }11651298 Ok(_) => {1166- std::fs::remove_file(&out_abs)1299+ std::fs::remove_file(&out_path)11671300 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;11681301 }11691302 Err(_) => {}11701303 }1171- std::fs::create_dir_all(&out_abs)1304+ std::fs::create_dir_all(&out_path)11721305 .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))11731306 }11741307 1308+/// Make `og:image` absolute on every built page when a public origin is1309+/// configured (f0rge #95). Done once over the output tree because the1310+/// origin is build config, not page content: all pages share the same1311+/// `content="/og.png"` marker.1312+fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {1313+ let base = origin.trim_end_matches('/');1314+ if base.is_empty() {1315+ return Ok(());1316+ }1317+ let absolute = format!("content=\"{}/og.png\"", esc(base));1318+ let mut stack = vec![out.to_path_buf()];1319+ while let Some(dir) = stack.pop() {1320+ let rd = std::fs::read_dir(&dir)1321+ .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;1322+ for ent in rd {1323+ let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;1324+ let path = ent.path();1325+ if path.is_dir() {1326+ stack.push(path);1327+ } else if path.extension().is_some_and(|x| x == "html") {1328+ let html = std::fs::read_to_string(&path)1329+ .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1330+ let replaced = html.replace("content=\"/og.png\"", &absolute);1331+ if replaced != html {1332+ std::fs::write(&path, replaced)1333+ .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;1334+ }1335+ }1336+ }1337+ }1338+ Ok(())1339+}1340+1341+/// Check an output tree against deployment limits. Returns the first1342+/// offending file (or the file-count overflow) with enough context to1343+/// fix the repo. Kept separate from the walk so tests can use tiny caps.1344+pub fn check_output_budget(1345+ out: &Path,1346+ max_file_bytes: u64,1347+ max_files: usize,1348+) -> Result<(), String> {1349+ let mut stack = vec![out.to_path_buf()];1350+ let mut files = 0usize;1351+ while let Some(dir) = stack.pop() {1352+ let rd = std::fs::read_dir(&dir)1353+ .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;1354+ for ent in rd {1355+ let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;1356+ let path = ent.path();1357+ let meta = ent1358+ .metadata()1359+ .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;1360+ if meta.is_dir() {1361+ stack.push(path);1362+ } else if meta.is_file() {1363+ files += 1;1364+ if files > max_files {1365+ return Err(format!(1366+ "hu13: output has more than {max_files} files (Cloudflare Pages limit); last: {}",1367+ path.display()1368+ ));1369+ }1370+ if meta.len() > max_file_bytes {1371+ return Err(format!(1372+ "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes; keep large assets out of git or use Git LFS",1373+ path.display(),1374+ meta.len(),1375+ max_file_bytes1376+ ));1377+ }1378+ }1379+ }1380+ }1381+ Ok(())1382+}1383+11751384 /// Build the full static site. Returns a summary on success.11761385 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {11771386 let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())@@ -1308,7 +1517,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu13081517 format!(".. ({parent})")13091518 };13101519 let mut body = breadcrumb(dir);1311- body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));1520+ body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));13121521 body.push_str(&file_table_html(13131522 dir,13141523 &entries,@@ -1407,7 +1616,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu14071616 &short_sha,14081617 desc_opt,14091618 )?;1410- feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;1619+ feed::render_loaded_with_origin(1620+ repo,1621+ out,1622+ &branch,1623+ &name,1624+ &commit_log,1625+ desc_opt,1626+ public_origin.as_deref(),1627+ )?;14111628 let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;14121629 // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.14131630 // Tasks/releases/wiki indexes always render even when empty (m6@@ -1511,6 +1728,28 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu15111728 std::fs::write(out.join("index.html"), index)15121729 .map_err(|e| format!("hu13: write index.html: {e}"))?;15131730 1731+ // Branded 404 for Pages/nginx; `serve` streams this file with status1732+ // 404 on unknown paths (f0rge #85). Same nav/footer as every page.1733+ let not_found = crate::layout::page_site_with_provenance(1734+ &format!("not found — {name}"),1735+ &nav,1736+ "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",1737+ &name,1738+ desc_opt,1739+ &branch,1740+ &short_sha,1741+ );1742+ std::fs::write(out.join("404.html"), not_found)1743+ .map_err(|e| format!("hu13: write 404.html: {e}"))?;1744+1745+ if let Some(origin) = public_origin.as_deref() {1746+ absolutize_social_meta(out, origin)?;1747+ }1748+1749+ // Fail loudly here instead of letting Pages reject an opaque deploy1750+ // (f0rge #81). Counts the whole emitted tree, including repo.git.1751+ check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;1752+15141753 Ok(BuildSummary {15151754 branch,15161755 short_sha,@@ -2235,6 +2474,33 @@ mod tests {22352474 assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");22362475 }22372476 2477+ #[test]2478+ fn output_budget_reports_file_size_and_count() {2479+ let dir = mktemp("budget");2480+ std::fs::write(dir.join("small.txt"), "ok").unwrap();2481+ std::fs::create_dir_all(dir.join("nested")).unwrap();2482+ std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();2483+ assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");2484+ assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");2485+ assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");2486+ std::fs::remove_dir_all(&dir).unwrap();2487+ }2488+2489+ #[test]2490+ fn hostile_dates_are_rejected() {2491+ // Hostile `.forge` timestamps must not panic (f0rge #82).2492+ assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());2493+ assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());2494+ assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());2495+ assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());2496+ assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());2497+ assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());2498+ assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());2499+ assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());2500+ assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());2501+ assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());2502+ }2503+22382504 #[test]22392505 fn relative_age_fixed_clock() {22402506 // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.
src/feed.rs+49 −4
--- a/src/feed.rs+++ b/src/feed.rs@@ -143,12 +143,31 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F143143 /// when present, becomes the feed `<subtitle>` (escaped; hostile markup144144 /// renders as text). `None` omits `<subtitle>` entirely.145145 #[must_use]146+#[allow(dead_code)]146147 pub fn render_feed(147148 site_name: &str,148149 branch: &str,149150 entries: &[FeedEntry],150151 description: Option<&str>,151152 ) -> String {153+ render_feed_with_origin(site_name, branch, entries, description, None)154+}155+156+/// [`render_feed`] with absolute entry links (f0rge #96): Atom readers157+/// disagree on relative IRIs, so prefix `/...` links with the public158+/// origin when one is configured. Already-absolute links stay untouched.159+#[must_use]160+pub fn render_feed_with_origin(161+ site_name: &str,162+ branch: &str,163+ entries: &[FeedEntry],164+ description: Option<&str>,165+ public_origin: Option<&str>,166+) -> String {167+ let origin = public_origin168+ .map(str::trim)169+ .map(|s| s.trim_end_matches('/'))170+ .filter(|s| !s.is_empty());152171 let updated = entries153172 .first()154173 .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));@@ -161,12 +180,22 @@ pub fn render_feed(161180 out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));162181 out.push_str(&format!("<updated>{updated}</updated>\n"));163182 out.push_str("<author><name>hu13</name></author>\n");183+ if let Some(base) = origin {184+ out.push_str(&format!(185+ "<link rel=\"self\" href=\"{}/feed.atom\"/>\n",186+ esc(base)187+ ));188+ }164189 for e in entries {165190 out.push_str("<entry>\n");166191 out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));167192 out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));168193 out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));169- out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));194+ let link = match origin {195+ Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link),196+ _ => e.link.clone(),197+ };198+ out.push_str(&format!("<link href=\"{}\"/>\n", esc(&link)));170199 out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));171200 out.push_str("</entry>\n");172201 }@@ -185,10 +214,11 @@ pub fn render(185214 description: Option<&str>,186215 ) -> Result<usize, String> {187216 let entries = collect_entries(repo, branch)?;188- render_entries(out, site_name, branch, &entries, description)217+ render_entries(out, site_name, branch, &entries, description, None)189218 }190219 191220 /// Build `feed.atom` from already-loaded commits (one struct pass).221+#[allow(dead_code)]192222 pub fn render_loaded(193223 repo: &Path,194224 out: &Path,@@ -196,9 +226,23 @@ pub fn render_loaded(196226 site_name: &str,197227 log: &[gitx::Commit],198228 description: Option<&str>,229+) -> Result<usize, String> {230+ render_loaded_with_origin(repo, out, branch, site_name, log, description, None)231+}232+233+/// [`render_loaded`] with absolute links for a configured public origin.234+#[allow(clippy::too_many_arguments)]235+pub fn render_loaded_with_origin(236+ repo: &Path,237+ out: &Path,238+ branch: &str,239+ site_name: &str,240+ log: &[gitx::Commit],241+ description: Option<&str>,242+ public_origin: Option<&str>,199243 ) -> Result<usize, String> {200244 let entries = collect_entries_loaded(repo, log)?;201- render_entries(out, site_name, branch, &entries, description)245+ render_entries(out, site_name, branch, &entries, description, public_origin)202246 }203247 204248 fn render_entries(@@ -207,8 +251,9 @@ fn render_entries(207251 branch: &str,208252 entries: &[FeedEntry],209253 description: Option<&str>,254+ public_origin: Option<&str>,210255 ) -> Result<usize, String> {211- let doc = render_feed(site_name, branch, entries, description);256+ let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin);212257 std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;213258 std::fs::write(out.join("feed.atom"), doc)214259 .map_err(|e| format!("hu13: write feed.atom: {e}"))?;
src/forge.rs+46 −9
--- a/src/forge.rs+++ b/src/forge.rs@@ -765,6 +765,26 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {765765 }766766 }767767 768+/// Remove builder-local absolute paths from published CI logs (f0rge769+/// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace770+/// the canonical repo root with `<repo>`, then `$HOME` with `~`.771+fn redact_paths(text: &str, root: &Path) -> String {772+ let mut out = text.to_string();773+ let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());774+ for p in [root_abs.as_path(), root] {775+ let needle = p.to_string_lossy();776+ if !needle.is_empty() && needle != "/" {777+ out = out.replace(needle.as_ref(), "<repo>");778+ }779+ }780+ if let Ok(home) = std::env::var("HOME") {781+ if !home.is_empty() && home != "/" {782+ out = out.replace(&home, "~");783+ }784+ }785+ out786+}787+768788 fn truncate_log(text: &str) -> String {769789 if text.len() <= LOG_PREVIEW_MAX {770790 return text.to_string();@@ -778,6 +798,8 @@ fn truncate_log(text: &str) -> String {778798 779799 fn load_ci(root: &Path) -> CiInfo {780800 let dir = root.join("ci");801+ // `root` is `<repo>/.forge`; logs name the repo checkout itself.802+ let repo_root = root.parent().unwrap_or(root);781803 let runner = if dir.join("ci.env").exists() {782804 match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {783805 Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {@@ -817,7 +839,8 @@ fn load_ci(root: &Path) -> CiInfo {817839 }818840 let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))819841 .unwrap_or_default();820- logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));842+ let text = String::from_utf8_lossy(&bytes);843+ logs.push((name, truncate_log(&redact_paths(&text, repo_root))));821844 }822845 Some(CiRun {823846 run: fm.run,@@ -832,7 +855,7 @@ fn load_ci(root: &Path) -> CiInfo {832855 duration_ms: s.duration_ms,833856 })834857 .collect(),835- body: body.to_string(),858+ body: redact_paths(body, repo_root),836859 logs,837860 })838861 });@@ -1142,14 +1165,19 @@ fn render_issues_index(11421165 // non-open (newest-first within). Numbers stay creation-rank.11431166 // m6/cards2 (#46 + #47): title-first column order.11441167 for issue in data.issues_in_display_order() {1168+ let state_plain = esc(&issue.state);1169+ let state_html = state_pill(&issue.state);1170+ let created_plain = time_ago(&issue.created, now);11451171 body.push_str(&format!(1146- "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",1172+ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",11471173 n = issue.number,11481174 id = esc(&issue.id),11491175 title = esc(&issue.title),1150- state = state_pill(&issue.state),1176+ state_plain = state_plain,1177+ state_html = state_html,11511178 comments = issue.comments.len(),1152- created = time_ago(&issue.created, now),1179+ created = esc(&created_plain),1180+ created_html = created_plain,11531181 ));11541182 }11551183 body.push_str("</tbody>\n</table></div>\n");@@ -1207,19 +1235,28 @@ fn render_pulls_index(12071235 // Numbers stay creation-rank.12081236 // m6/cards2 (#46 + #47): title-first column order.12091237 for pr in data.prs_in_display_order() {1210- let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1238+ let lanes = format!(1239+ "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1240+ esc(&pr.head),1241+ esc(&pr.base)1242+ );12111243 let lanes_plain = format!("{}→{}", pr.head, pr.base);12121244 let draft = if pr.draft { " draft" } else { "" };12131245 let full_title = format!("{}{}", pr.title, draft);1246+ let state_plain = esc(&pr.state);1247+ let state_html = state_pill(&pr.state);1248+ let created_plain = time_ago(&pr.created, now);12141249 body.push_str(&format!(1215- "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code>{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",1250+ "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code class=\"lanes\">{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",12161251 n = pr.number,12171252 id = esc(&pr.id),12181253 title = esc(&pr.title),12191254 full = esc(&full_title),1220- state = state_pill(&pr.state),1255+ state_plain = state_plain,1256+ state_html = state_html,12211257 lanes_title = esc(&lanes_plain),1222- created = time_ago(&pr.created, now),1258+ created = esc(&created_plain),1259+ created_html = created_plain,12231260 ));12241261 }12251262 body.push_str("</tbody>\n</table></div>\n");
src/highlight.rs+58 −23
--- a/src/highlight.rs+++ b/src/highlight.rs@@ -235,48 +235,74 @@ pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {235235 Some(out)236236 }237237 238+fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {239+ for (i, c) in (at..).zip(pat.chars()) {240+ if chars.get(i) != Some(&c) {241+ return false;242+ }243+ }244+ true245+}246+247+/// Find `pat` at or after char index `from`, without building a248+/// `String` for the rest of the line (the old scanner did that once per249+/// character, which made minified files quadratic; f0rge #78).250+fn find_at(chars: &[char], from: usize, pat: &str) -> Option<usize> {251+ let len = pat.chars().count();252+ if len == 0 {253+ return Some(from);254+ }255+ let last = chars.len().checked_sub(len)?;256+ if from > last {257+ return None;258+ }259+ (from..=last).find(|&i| starts_with_at(chars, i, pat))260+}261+262+fn slice(chars: &[char], from: usize, to: usize) -> String {263+ chars[from..to].iter().collect()264+}265+238266 fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {239267 let chars: Vec<char> = line.chars().collect();240268 let mut out = String::with_capacity(line.len() + 16);241269 let mut i = 0;242270 while i < chars.len() {243- let rest: String = chars[i..].iter().collect();244-245271 if *in_block {246272 if let Some((_, end)) = lang.block {247- if let Some(pos) = rest.find(end) {248- let stop = i + rest[..pos].chars().count() + end.chars().count();249- let piece: String = chars[i..stop].iter().collect();250- push_span(&mut out, "com", &piece);273+ if let Some(end_at) = find_at(&chars, i, end) {274+ let stop = end_at + end.chars().count();275+ push_span(&mut out, "com", &slice(&chars, i, stop));251276 *in_block = false;252277 i = stop;253278 continue;254279 }255280 }256- let piece: String = chars[i..].iter().collect();257- push_span(&mut out, "com", &piece);281+ push_span(&mut out, "com", &slice(&chars, i, chars.len()));258282 return out;259283 }260284 261285 if let Some((start, end)) = lang.block {262- if let Some(after_start) = rest.strip_prefix(start) {263- if let Some(pos) = after_start.find(end) {264- let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();265- let piece: String = chars[i..stop].iter().collect();266- push_span(&mut out, "com", &piece);286+ if starts_with_at(&chars, i, start) {287+ let after_start = i + start.chars().count();288+ if let Some(end_at) = find_at(&chars, after_start, end) {289+ let stop = end_at + end.chars().count();290+ push_span(&mut out, "com", &slice(&chars, i, stop));267291 i = stop;268292 continue;269293 }270294 *in_block = true;271- let piece: String = chars[i..].iter().collect();272- push_span(&mut out, "com", &piece);295+ push_span(&mut out, "com", &slice(&chars, i, chars.len()));273296 return out;274297 }275298 }276299 277- if lang.line_comment.iter().any(|m| rest.starts_with(m)) {278- let piece: String = chars[i..].iter().collect();279- push_span(&mut out, "com", &piece);300+ if lang301+ .line_comment302+ .iter()303+ .any(|m| starts_with_at(&chars, i, m))304+ {305+ push_span(&mut out, "com", &slice(&chars, i, chars.len()));280306 return out;281307 }282308 @@ -296,8 +322,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {296322 }297323 j += 1;298324 }299- let piece: String = chars[i..j].iter().collect();300- push_span(&mut out, "str", &piece);325+ push_span(&mut out, "str", &slice(&chars, i, j));301326 i = j;302327 continue;303328 }@@ -309,8 +334,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {309334 {310335 j += 1;311336 }312- let piece: String = chars[i..j].iter().collect();313- push_span(&mut out, "num", &piece);337+ push_span(&mut out, "num", &slice(&chars, i, j));314338 i = j;315339 continue;316340 }@@ -320,7 +344,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {320344 while j < chars.len() && is_ident(chars[j]) {321345 j += 1;322346 }323- let word: String = chars[i..j].iter().collect();347+ let word = slice(&chars, i, j);324348 let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());325349 let call = next_non_space.is_some_and(|c| *c == '(');326350 let class = if lang.keywords.contains(&word.as_str()) {@@ -395,6 +419,17 @@ mod tests {395419 assert!(lines[0].contains("<script>"));396420 }397421 422+ #[test]423+ fn long_single_line_is_not_quadratic() {424+ // 64 KiB on one line took ~27s with the per-char suffix clone;425+ // the linear scanner stays far below this coarse bound (f0rge #78).426+ let big = "var x=1;".repeat(8000);427+ let start = std::time::Instant::now();428+ let lines = highlight_lines("min.js", &big).expect("js");429+ assert!(start.elapsed() < std::time::Duration::from_secs(5));430+ assert!(!lines[0].is_empty());431+ }432+398433 #[test]399434 fn oversized_inputs_stay_plain() {400435 let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);
src/layout.rs+65 −22
--- a/src/layout.rs+++ b/src/layout.rs@@ -57,10 +57,13 @@ pub fn diff_html(raw: &str) -> String {5757 let path = diff_chunk_path(chunk);5858 let mut added = 0usize;5959 let mut removed = 0usize;60+ let mut counting_hunk = false;6061 for line in chunk {61- if line.starts_with('+') && !line.starts_with("+++") {62+ if line.starts_with("@@") {63+ counting_hunk = true;64+ } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) {6265 added += 1;63- } else if line.starts_with('-') && !line.starts_with("---") {66+ } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) {6467 removed += 1;6568 }6669 }@@ -82,13 +85,10 @@ pub fn diff_html(raw: &str) -> String {8285 8386 let mut old_ln = 0i64;8487 let mut new_ln = 0i64;85- let mut first = true;88+ let mut in_hunk = false;8689 for line in chunk {87- if !first {88- out.push('\n');89- }90- first = false;9190 if line.starts_with("@@") {91+ in_hunk = true;9292 let mut parts = line.split_whitespace();9393 let _ = parts.next();9494 if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {@@ -106,10 +106,22 @@ pub fn diff_html(raw: &str) -> String {106106 .unwrap_or(0);107107 }108108 out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));109- } else if line.starts_with('+') && !line.starts_with("+++") {109+ } else if line.starts_with("+++") || line.starts_with("---") {110+ if in_hunk {111+ if line.starts_with('+') {112+ out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));113+ new_ln += 1;114+ } else {115+ out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));116+ old_ln += 1;117+ }118+ } else {119+ out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));120+ }121+ } else if line.starts_with('+') {110122 out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));111123 new_ln += 1;112- } else if line.starts_with('-') && !line.starts_with("---") {124+ } else if line.starts_with('-') {113125 out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));114126 old_ln += 1;115127 } else if line.starts_with(' ') {@@ -121,8 +133,6 @@ pub fn diff_html(raw: &str) -> String {121133 ));122134 old_ln += 1;123135 new_ln += 1;124- } else if line.starts_with("+++") || line.starts_with("---") {125- out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));126136 } else {127137 out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));128138 }@@ -142,14 +152,15 @@ fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {142152 /// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the143153 /// `diff --git` header. `/dev/null` falls through to the other side.144154 fn diff_chunk_path(lines: &[&str]) -> String {145- for line in lines {155+ let headers = lines.iter().take_while(|l| !l.starts_with("@@"));156+ for line in headers.clone() {146157 if let Some(rest) = line.strip_prefix("+++ ") {147158 if rest != "/dev/null" {148159 return rest.strip_prefix("b/").unwrap_or(rest).to_string();149160 }150161 }151162 }152- for line in lines {163+ for line in headers {153164 if let Some(rest) = line.strip_prefix("--- ") {154165 if rest != "/dev/null" {155166 return rest.strip_prefix("a/").unwrap_or(rest).to_string();@@ -168,16 +179,12 @@ fn diff_chunk_path(lines: &[&str]) -> String {168179 /// with line-level coloring (markers stay span-free, old behavior).169180 fn plain_diff_html(raw: &str) -> String {170181 let mut out = String::with_capacity(raw.len() + raw.len() / 4);171- let mut first = true;172182 for line in raw.lines() {173- if !first {174- out.push('\n');175- }176- first = false;183+ // One block span per line; no literal newlines between spans or184+ // `white-space:pre` inserts a blank row (f0rge #86).185+ out.push_str("<span class=\"dline\">");177186 out.push_str(&diff_line_span(line));178- }179- if raw.ends_with('\n') {180- out.push('\n');187+ out.push_str("</span>");181188 }182189 out183190 }@@ -322,7 +329,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin322329 }323330 }324331 format!(325- "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\">gl0w</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/inbox/issue\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>"332+ "<footer class=\"site-footer\"><div class=\"wrap\"><div class=\"footer-brand\"><a class=\"gl0w-mark\" href=\"https://gl0w.world\" rel=\"noopener\">gl0w</a><span class=\"footer-tag\">hu13 — a gl0w project</span></div><p class=\"footer-links\"><a href=\"https://gl0w.world\" rel=\"noopener\">gl0w.world</a><a href=\"/agents.html\">agents</a><a href=\"/new-issue.html\">submit</a></p><p class=\"muted footer-provenance\">{line}</p></div></footer>"326333 )327334 }328335 @@ -644,6 +651,42 @@ mod tests {644651 assert!(!html.contains("<span> line one"), "context has no span");645652 }646653 654+ #[test]655+ fn diff_rows_have_no_blank_lines_and_content_headers_count() {656+ // f0rge #86: no literal newline between block rows.657+ // f0rge #87: `++++` after a hunk is added content, not a header.658+ let raw = "diff --git a/a.txt b/a.txt\n\659+--- a/a.txt\n+++ b/a.txt\n\660+@@ -1 +1,4 @@\n context\n\661+++++ not a header\n+--- not a header\n----- removed content\n+plain\n";662+ let html = diff_html(raw);663+ assert!(664+ !html.contains("</span>\n<span class=\"df-line\""),665+ "no newline between diff rows: {html}"666+ );667+ assert!(668+ html.contains("<span class=\"df-add\">+3</span>"),669+ "three added lines, including +++ content: {html}"670+ );671+ assert!(672+ html.contains("<span class=\"df-del\">−1</span>"),673+ "one removed line, including --- content: {html}"674+ );675+ assert!(676+ html.contains("<span class=\"diff-add\">++++ not a header</span>"),677+ "added +++ content keeps its marker and is not a file header: {html}"678+ );679+ assert!(680+ html.contains("<span class=\"diff-del\">----- removed content</span>"),681+ "removed --- content is a removal: {html}"682+ );683+ assert_eq!(684+ html.matches("<span class=\"diff-file\">").count(),685+ 2,686+ "only the real ---/+++ file headers"687+ );688+ }689+647690 #[test]648691 fn diff_html_escapes_before_wrapping() {649692 let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");
src/serve.rs+61 −26
--- a/src/serve.rs+++ b/src/serve.rs@@ -485,7 +485,9 @@ const RAW_ACTIVE_EXTS: &[&str] = &[485485 "pdf", "swf", "hta", "jar", "crx", "xpi",486486 ];487487 488-/// Text-ish extensions safe to show inline from `/raw/`.488+/// Text-ish extensions that keep a text MIME type. They still download:489+/// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses490+/// the same disposition instead of rendering text inline (f0rge #93).489491 const RAW_TEXT_EXTS: &[&str] = &[490492 "txt",491493 "text",@@ -550,14 +552,14 @@ fn extension(name: &str) -> String {550552 ///551553 /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run552554 /// script in the site's origin (where the inbox endpoints live). Active553-/// types become an octet-stream download; text stays inline as554-/// `text/plain`; everything else downloads.555+/// types become an octet-stream download; text keeps `text/plain` but556+/// also downloads; everything downloads with `attachment` (f0rge #93).555557 fn raw_content_type(name: &str) -> (&'static str, &'static str) {556558 let ext = extension(name);557559 if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {558560 ("application/octet-stream", "attachment")559561 } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {560- ("text/plain; charset=utf-8", "inline")562+ ("text/plain; charset=utf-8", "attachment")561563 } else {562564 ("application/octet-stream", "attachment")563565 }@@ -706,13 +708,17 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {706708 };707709 let dest = absolutize(&shared.www.join(&file_rel));708710 if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {709- static_error(&mut stream, 404);711+ static_not_found(&mut stream, &head, shared);710712 return;711713 }712- let bytes = match std::fs::read(&dest) {713- Ok(b) if dest.is_file() => b,714+ // Stat, then stream. Never read a static file into memory: a raw715+ // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The716+ // method check stays after existence so an unknown POST is still717+ // 404 (fail closed, no route discovery).718+ let meta = match std::fs::metadata(&dest) {719+ Ok(m) if m.is_file() => m,714720 _ => {715- static_error(&mut stream, 404);721+ static_not_found(&mut stream, &head, shared);716722 return;717723 }718724 };@@ -732,22 +738,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {732738 &[]733739 };734740 if head.method == "HEAD" {735- let mut out = format!(736- "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",737- bytes.len()738- );739- for (k, v) in extra {740- out.push_str(k);741- out.push_str(": ");742- out.push_str(v);743- out.push_str("\r\n");744- }745- out.push_str("\r\n");746- let _ = stream.write_all(out.as_bytes());747- let _ = stream.flush();741+ respond_file_head(&mut stream, 200, ctype, extra, meta.len());748742 return;749743 }750- respond(&mut stream, 200, ctype, extra, &bytes);744+ respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());751745 }752746 753747 /// Read-and-discard in-flight request bytes (bounded) so early rejects@@ -800,13 +794,12 @@ impl Drop for GitSlot {800794 }801795 }802796 803-/// Stream a file body with `Content-Length` (no full-pack buffering).804-fn respond_file(797+/// Headers for a file response, without opening the file body.798+fn respond_file_head(805799 stream: &mut TcpStream,806800 code: u16,807801 content_type: &str,808802 extra: &[(&str, &str)],809- path: &Path,810803 len: u64,811804 ) {812805 let mut out = format!(@@ -820,9 +813,51 @@ fn respond_file(820813 out.push_str("\r\n");821814 }822815 out.push_str("\r\n");823- if stream.write_all(out.as_bytes()).is_err() {816+ let _ = stream.write_all(out.as_bytes());817+ let _ = stream.flush();818+}819+820+/// Serve the built `404.html` (same chrome as every page) with status821+/// 404. Falls back to the tiny in-binary stub when the file is absent.822+/// POSTs keep the old non-echoing stub (`f0rge #85`).823+fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {824+ if head.method != "GET" && head.method != "HEAD" {825+ static_error(stream, 404);824826 return;825827 }828+ let dest = shared.www.join("404.html");829+ let Ok(meta) = std::fs::metadata(&dest) else {830+ static_error(stream, 404);831+ return;832+ };833+ if !meta.is_file() {834+ static_error(stream, 404);835+ return;836+ }837+ if head.method == "HEAD" {838+ respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());839+ } else {840+ respond_file(841+ stream,842+ 404,843+ "text/html; charset=utf-8",844+ &[],845+ &dest,846+ meta.len(),847+ );848+ }849+}850+851+/// Stream a file body with `Content-Length` (no full-pack buffering).852+fn respond_file(853+ stream: &mut TcpStream,854+ code: u16,855+ content_type: &str,856+ extra: &[(&str, &str)],857+ path: &Path,858+ len: u64,859+) {860+ respond_file_head(stream, code, content_type, extra, len);826861 if let Ok(mut f) = std::fs::File::open(path) {827862 let _ = std::io::copy(&mut f, stream);828863 }
static/style.css+24 −5
--- a/static/style.css+++ b/static/style.css@@ -17,6 +17,7 @@1717 --yellow:#e0af68;1818 --red:#f7768e;1919 --border:#292e42;20+ --field-border:#6a7590;2021 --visited:#9aa5ce;2122 --btn-fg:#16161e;2223 --state-open:#9ece6a;@@ -66,6 +67,7 @@6667 --yellow:#7d5f33;6768 --red:#8c4351;6869 --border:#d6dae2;70+ --field-border:#8a93a5;6971 --visited:#5a4a78;7072 --btn-fg:#ffffff;7173 --state-open:#33635c;@@ -77,9 +79,9 @@7779 --tok-com:#4a5273;7880 --tok-fn:#34548a;7981 --tok-type:#0f4b6e;80- --diff-add:#33635c;82+ --diff-add:#2a534d;8183 --diff-add-bg:#c6d4ca;82- --diff-del:#8c4351;84+ --diff-del:#7a3543;8385 --diff-del-bg:#d9c2c7;8486 --diff-hunk:#0f4b6e;8587 --diff-file:#5a4a78;@@ -141,8 +143,8 @@ td.num{width:1%;white-space:nowrap}141143 /* Forms: full-width on mobile, >=40px touch targets. */142144 form{max-width:44rem;margin:1rem 0;min-width:0}143145 label{display:block;margin:.75rem 0 .25rem;font-weight:600}144-input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt);145- color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px}146+input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--surface);147+ color:var(--fg);border:1px solid var(--field-border);padding:.5rem .6rem;font:inherit;min-height:40px}146148 input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0}147149 textarea{min-height:12rem}148150 button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;@@ -193,6 +195,7 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align193195 .site-logo{display:block;width:auto;height:1.75rem;flex:none}194196 .hu13-word{font-weight:700;color:var(--blue)}195197 .sep{flex:none;color:var(--muted)}198+.nav-sep{flex:0 0 auto;color:var(--muted)}196199 .site-header .site-nav.nav-scroll{flex:1 1 auto;min-width:0;background:none;border-bottom:none;overflow-x:auto;scroll-snap-type:none;-webkit-overflow-scrolling:touch}197200 .nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}198201 .site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}@@ -362,6 +365,7 @@ time{font-variant-numeric:tabular-nums}362365 .df-bar-add{display:block;height:100%;background:var(--green)}363366 .difffile .pre-scroll{margin:0}364367 pre.diff{margin:0;border:0;background:var(--surface)}368+pre.diff>span{display:block}365369 .df-line{display:block}366370 .dln{display:inline-block;width:2.8em;text-align:right;padding-right:.5em;color:var(--fg-dim);user-select:none;font-variant-numeric:tabular-nums}367371 .dln+.dln{border-right:1px solid var(--border);margin-right:.5em}@@ -370,11 +374,26 @@ pre.diff{margin:0;border:0;background:var(--surface)}370374 .diff-file{color:var(--diff-file);font-weight:600}371375 .df-meta{color:var(--fg-dim)}372376 377+/* Tablet nav (f0rge #91): 768–1099px scrolls off the last tab with no378+ affordance. Give the nav its own wrapped row until the full one-row379+ header fits at 1100px; separators would otherwise begin a line. */380+@media (min-width:768px) and (max-width:1099px){381+ .site-header .wrap.header-bar{flex-wrap:wrap}382+ .site-header .sep,.site-header .nav-sep{display:none}383+ .site-header .site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible;border-top:1px solid var(--border);margin-top:var(--s1);padding-top:var(--s1)}384+ .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}385+}386+/* Branch lanes (f0rge #92): never break inside a branch name; wrap only387+ between head, arrow and base (a very long token scrolls its cell).388+ Mobile card cells inherit word-break:break-all, so reset it here. */389+.lanes{display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:.15rem .35rem;max-width:100%;overflow-x:auto;word-break:normal;overflow-wrap:normal}390+.lanes .lane{white-space:nowrap}391+.lanes .lane-arrow{color:var(--muted)}373392 /* Responsive fixes (f0rge #75): every nav destination visible at 390px,374393 file table keeps Name + Age, meta chips never overflow. */375394 @media (max-width:767px){376395 .site-header .wrap.header-bar{flex-wrap:wrap}377- .site-header .sep{display:none}396+ .site-header .sep,.site-header .nav-sep{display:none}378397 .site-header .site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible;border-top:1px solid var(--border);margin-top:var(--s1);padding-top:var(--s1)}379398 .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}380399 /* Dense file table: drop "Last commit" below 768px instead of wrapping
tests/clean_out.rs+58 −0
--- a/tests/clean_out.rs+++ b/tests/clean_out.rs@@ -122,3 +122,61 @@ fn refuses_to_clean_repo_or_its_parent() {122122 assert!(sender.is_file(), "repo untouched");123123 }124124 }125+126+#[test]127+fn refuses_relative_dotdot_output_and_keeps_parent() {128+ let root = fresh_dir("dotdot");129+ let repo = root.join("repo");130+ std::fs::create_dir_all(repo.join("src")).unwrap();131+ git(&repo, &["init", "-b", "main"]);132+ git(&repo, &["config", "user.name", "hu13-test"]);133+ git(&repo, &["config", "user.email", "[email protected]"]);134+ git(&repo, &["config", "commit.gpgsign", "false"]);135+ std::fs::write(repo.join("README.md"), "# keep\n").unwrap();136+ std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();137+ git(&repo, &["add", "-A"]);138+ git(&repo, &["commit", "-m", "one"]);139+ let canary = root.join("canary.txt");140+ std::fs::write(&canary, "must survive\n").unwrap();141+142+ let res = Command::new(env!("CARGO_BIN_EXE_hu13"))143+ .arg("build")144+ .arg(&repo)145+ .arg("-o")146+ .arg("..")147+ .current_dir(&repo)148+ .output()149+ .expect("spawn hu13 build");150+ assert!(!res.status.success(), "relative .. must be refused");151+ let err = String::from_utf8_lossy(&res.stderr);152+ assert!(err.contains("refusing"), "stderr: {err}");153+ assert!(canary.is_file(), "parent canary must survive");154+ assert!(repo.join("README.md").is_file(), "repo must survive");155+}156+157+#[cfg(unix)]158+#[test]159+fn refuses_symlinked_ancestor_into_repo() {160+ let root = fresh_dir("symlink");161+ let repo = root.join("repo");162+ std::fs::create_dir_all(repo.join("src")).unwrap();163+ git(&repo, &["init", "-b", "main"]);164+ git(&repo, &["config", "user.name", "hu13-test"]);165+ git(&repo, &["config", "user.email", "[email protected]"]);166+ git(&repo, &["config", "commit.gpgsign", "false"]);167+ std::fs::write(repo.join("README.md"), "# keep\n").unwrap();168+ std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();169+ git(&repo, &["add", "-A"]);170+ git(&repo, &["commit", "-m", "one"]);171+ let alias = root.join("alias");172+ std::os::unix::fs::symlink(&repo, &alias).unwrap();173+174+ let res = build(&repo, &alias.join("src"));175+ assert!(!res.status.success(), "symlinked src must be refused");176+ let err = String::from_utf8_lossy(&res.stderr);177+ assert!(err.contains("refusing"), "stderr: {err}");178+ assert!(179+ repo.join("src/main.rs").is_file(),180+ "tracked source must survive"181+ );182+}
tests/light_theme.rs+3 −3
--- a/tests/light_theme.rs+++ b/tests/light_theme.rs@@ -167,7 +167,7 @@ fn no_hardcoded_palette_hex_outside_root() {167167 "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e",168168 "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328",169169 "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7",170- "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e",170+ "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", "#2a534d", "#7a3543", "#6a7590", "#8a93a5",171171 ];172172 for hex in palette {173173 assert!(@@ -200,8 +200,8 @@ fn diff_vars_defined_dark_and_light() {200200 "--diff-add:#9ece6a",201201 "--diff-del:#f7768e",202202 "--diff-hunk:#7dcfff",203- "--diff-add:#33635c",204- "--diff-del:#8c4351",203+ "--diff-add:#2a534d",204+ "--diff-del:#7a3543",205205 "--diff-hunk:#0f4b6e",206206 ] {207207 assert!(css.contains(var), "diff var defined: {var}");
tests/pages_inbox.test.mjs+159 −0
--- /dev/null+++ b/tests/pages_inbox.test.mjs@@ -0,0 +1,159 @@1+// Cloudflare Pages inbox parity tests (f0rge #79). Run:2+// node --test tests/pages_inbox.test.mjs3+// No network, no wrangler: a fake R2 bucket exercises validation,4+// origin gate, ack, storage shape, rate and quota.5+import test from "node:test";6+import assert from "node:assert/strict";7+import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js";8+9+class FakeBucket {10+ constructor() {11+ this.objects = new Map();12+ }13+ async list({ limit = 1000 } = {}) {14+ return {15+ objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),16+ truncated: this.objects.size > limit,17+ };18+ }19+ async put(key, value) {20+ this.objects.set(key, String(value));21+ }22+}23+24+let seq = 0;25+function env(extra = {}) {26+ return {27+ INBOX: new FakeBucket(),28+ INBOX_SALT: "test-salt",29+ ...extra,30+ };31+}32+33+function post(kind, body, headers = {}, e = env()) {34+ const request = new Request(`https://hu13.test/inbox/${kind}`, {35+ method: "POST",36+ headers: {37+ "content-type": "application/x-www-form-urlencoded",38+ "cf-connecting-ip": `203.0.113.${++seq}`,39+ ...headers,40+ },41+ body,42+ });43+ return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({44+ res,45+ text: await res.text(),46+ env: e,47+ }));48+}49+50+const validIssue = "title=Hello&body=World&license=agree";51+const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on";52+53+test("GET is 405 with POST allowed and no-store/nosniff", async () => {54+ const res = await onRequestGet();55+ assert.equal(res.status, 405);56+ assert.equal(res.headers.get("allow"), "POST");57+ assert.equal(res.headers.get("x-content-type-options"), "nosniff");58+ assert.equal(res.headers.get("cache-control"), "no-store");59+});60+61+test("valid issue stores one JSON object and returns an ack", async () => {62+ const e = env();63+ const { res, text } = await post("issue", validIssue, {}, e);64+ assert.equal(res.status, 200);65+ assert.match(text, /ack: <code>[0-9a-f-]{36}<\/code>/);66+ assert.equal(e.INBOX.objects.size, 1);67+ const [key, value] = [...e.INBOX.objects.entries()][0];68+ assert.match(key, /^[0-9a-f-]{36}\.json$/);69+ const doc = JSON.parse(value);70+ assert.equal(doc.type, "issue");71+ assert.equal(doc.title, "Hello");72+ assert.equal(doc.body, "World");73+ assert.equal(doc.license_version, "v1");74+ assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);75+ assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/);76+ assert.ok(!text.includes("World"), "ack never reflects input");77+});78+79+test("valid pr-prompt uses prompt (and accepts body alias)", async () => {80+ for (const body of [validPrompt, "title=P&body=alias&license=yes"]) {81+ const e = env();82+ const { res } = await post("pr-prompt", body, {}, e);83+ assert.equal(res.status, 200);84+ const doc = JSON.parse([...e.INBOX.objects.values()][0]);85+ assert.equal(doc.type, "pr-prompt");86+ assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias");87+ }88+});89+90+test("missing license, empty fields and oversize are 400", async () => {91+ for (const body of [92+ "title=Hello&body=World",93+ "title=&body=World&license=agree",94+ "title=Hello&body=&license=agree",95+ `title=${"x".repeat(301)}&body=W&license=agree`,96+ ]) {97+ const { res } = await post("issue", body);98+ assert.equal(res.status, 400, body);99+ }100+});101+102+test("honeypot fakes success without storing", async () => {103+ const e = env();104+ const { res, text } = await post(105+ "issue",106+ "title=Hello&body=World&license=agree&website=bot",107+ {},108+ e,109+ );110+ assert.equal(res.status, 200);111+ assert.match(text, /ack: <code>/);112+ assert.equal(e.INBOX.objects.size, 0);113+});114+115+test("malformed escapes, non-UTF-8 and NUL are 400", async () => {116+ for (const body of [117+ "title=%ZZ&body=W&license=agree",118+ "title=%FF&body=W&license=agree",119+ "title=He%00llo&body=W&license=agree",120+ ]) {121+ const { res } = await post("issue", body);122+ assert.equal(res.status, 400, body);123+ }124+});125+126+test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => {127+ const bad = await post("issue", validIssue, { origin: "https://evil.test" });128+ assert.equal(bad.res.status, 403);129+ const nullOrigin = await post("issue", validIssue, { origin: "null" });130+ assert.equal(nullOrigin.res.status, 403);131+ const same = await post("issue", validIssue, { origin: "https://hu13.test" });132+ assert.equal(same.res.status, 200);133+});134+135+test("wrong content type and oversized bodies are rejected", async () => {136+ const json = await post("issue", validIssue, {137+ "content-type": "application/json",138+ });139+ assert.equal(json.res.status, 400);140+ const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`;141+ const over = await post("issue", big);142+ assert.equal(over.res.status, 413);143+});144+145+test("quota is enforced before storing", async () => {146+ const e = env({ INBOX_QUOTA: "1" });147+ const first = await post("issue", validIssue, {}, e);148+ assert.equal(first.res.status, 200);149+ const second = await post("issue", validIssue, {}, e);150+ assert.equal(second.res.status, 503);151+ assert.equal(e.INBOX.objects.size, 1);152+});153+154+test("missing R2 binding or salt fails closed", async () => {155+ const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });156+ assert.equal(noBucket.res.status, 503);157+ const noSalt = await post("issue", validIssue, {}, { INBOX: new FakeBucket() });158+ assert.equal(noSalt.res.status, 503);159+});
tests/raw_safety.rs+5 −1
--- a/tests/raw_safety.rs+++ b/tests/raw_safety.rs@@ -147,7 +147,11 @@ fn raw_active_types_download_never_render() {147147 Some("text/plain; charset=utf-8"),148148 "{path}"149149 );150- assert_eq!(header(&resp, "Content-Disposition"), Some("inline"));150+ assert_eq!(151+ header(&resp, "Content-Disposition"),152+ Some("attachment"),153+ "{path} downloads, matching _headers"154+ );151155 }152156 153157 let resp = get(&srv.addr, "/raw/blob.bin");
tests/review3_render.rs+25 −0
--- /dev/null+++ b/tests/review3_render.rs@@ -0,0 +1,264 @@1+//! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer,2+//! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social3+//! and feed URLs, dir headings.4+5+use std::io::{BufRead, BufReader, Read, Write};6+use std::net::TcpStream;7+use std::path::{Path, PathBuf};8+use std::process::{Child, Command, Stdio};9+use std::sync::atomic::{AtomicU64, Ordering};10+use std::time::Duration;11+12+static SEQ: AtomicU64 = AtomicU64::new(0);13+14+fn fresh(prefix: &str) -> PathBuf {15+ let dir = std::env::temp_dir().join(format!(16+ "hu13-review3-{prefix}-{}-{}",17+ std::process::id(),18+ std::time::SystemTime::now()19+ .duration_since(std::time::UNIX_EPOCH)20+ .map(|d| d.as_nanos())21+ .unwrap_or(0)22+ .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))23+ ));24+ std::fs::create_dir_all(&dir).unwrap();25+ dir