record #59 merge

2cb3661gl0w

all commits

Message

record #59 merge

Diff

.forge/merges/2026-10-27T17-43-25.698219166Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T17-43-25.698219166Z.md b/.forge/merges/2026-10-27T17-43-25.698219166Z.mdnew file mode 100644index 0000000..c287c34--- /dev/null+++ b/.forge/merges/2026-10-27T17-43-25.698219166Z.md@@ -0,0 +1,9 @@1+---2+pr: 146353cc-69c7-4238-bfd3-d2adebd844f73+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T17:43:25.698219166Z7+---8+clean apply9+patch: 6bdb6d6a7cbf3142d07ce4ca74f38bc10f1531b3
.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff+1973 −0
diff --git a/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff b/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diffnew file mode 100644index 0000000..5eef732--- /dev/null+++ b/.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff@@ -0,0 +1,2322 @@1+From 8a1ebeffb2714b192636d23a3221e001aad730ea Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 17:41:42 +00004+Subject: [PATCH] review #3: harden build/serve, fix render regressions, add5+ Pages inbox (#77-#96)6+7+---8+ .forge/ci/steps/04_pages_inbox.sh |   8 +9+ .forge/wiki/deploy                |   5 +10+ docs/cloudflare.md                |  95 ++++++++11+ functions/inbox/[kind].js         | 349 ++++++++++++++++++++++++++++++12+ src/build.rs                      | 304 ++++++++++++++++++++++++--13+ src/feed.rs                       |  53 ++++-14+ src/forge.rs                      |  55 ++++-15+ src/highlight.rs                  |  81 +++++--16+ src/layout.rs                     |  87 ++++++--17+ src/serve.rs                      |  87 +++++---18+ static/style.css                  |  29 ++-19+ tests/clean_out.rs                |  58 +++++20+ tests/light_theme.rs              |   6 +-21+ tests/pages_inbox.test.mjs        | 159 ++++++++++++++22+ tests/raw_safety.rs               |   6 +-23+ tests/review3_render.rs           | 264 ++++++++++++++++++++++24+ tests/theme_contrast.rs           |  23 ++25+ wrangler.toml                     |   9 +26+ 18 files changed, 1566 insertions(+), 112 deletions(-)27+ create mode 100755 .forge/ci/steps/04_pages_inbox.sh28+ create mode 100644 docs/cloudflare.md29+ create mode 100644 functions/inbox/[kind].js30+ create mode 100644 tests/pages_inbox.test.mjs31+ create mode 100644 tests/review3_render.rs32+ create mode 100644 wrangler.toml33+34+diff --git a/.forge/ci/steps/04_pages_inbox.sh b/.forge/ci/steps/04_pages_inbox.sh35+new file mode 10075536+index 0000000..bc39c5a37+--- /dev/null38++++ b/.forge/ci/steps/04_pages_inbox.sh39+@@ -0,0 +1,8 @@40++#!/bin/sh41++# Cloudflare Pages inbox parity (f0rge #79). Skips only when Node is absent.42++set -eu43++if ! command -v node >/dev/null 2>&1; then44++    echo "pages inbox: node not installed; skipping"45++    exit 046++fi47++node --test tests/pages_inbox.test.mjs48+diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy49+index 3e94c60..1fd24c2 10064450+--- a/.forge/wiki/deploy51++++ b/.forge/wiki/deploy52+@@ -69,6 +69,11 @@ What happens inside (`entrypoint.sh`):53+   `dist/_headers` carries the `/raw/**` download policy for Pages. On54+   nginx, add the equivalent `add_header Content-Disposition attachment`55+   for `/raw/` — committed HTML/SVG must never render from that path.56++- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The57++  Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and58++  secret `INBOX_SALT` implement the same contract. See59++  `docs/cloudflare.md` for the bucket, binding, secret and WAF rate-rule60++  recipe. `dist/_routes.json` limits Functions to `/inbox/*`.61+ 62+ - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.63+   If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`64+diff --git a/docs/cloudflare.md b/docs/cloudflare.md65+new file mode 10064466+index 0000000..dedbd9d67+--- /dev/null68++++ b/docs/cloudflare.md69+@@ -0,0 +1,95 @@70++# Deploy hu13 on Cloudflare Pages (Free)71++72++The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On73++Pages the inbox is implemented by a Pages Function backed by R2; a74++static-only upload has no working inbox.75++76++## 1. Build the site77++78++```sh79++cargo build --release80++./target/release/hu13 build . -o dist \81++  --public-origin https://hu13.example.com82++```83++84++`--public-origin` makes the clone command, `og:image`, Atom links and85++canonical URLs absolute. The build refuses output over Cloudflare's86++limits (20,000 files, 25 MiB/file) and writes `_headers` and87++`_routes.json` into `dist/`.88++89++## 2. Create the R2 bucket and Pages project90++91++```sh92++npx wrangler r2 bucket create hu13-inbox93++npx wrangler pages project create hu1394++```95++96++Bind the bucket to the Pages project as **`INBOX`** (dashboard:97++Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in98++`wrangler.toml`). Set the required secret:99++100++```sh101++npx wrangler pages secret put INBOX_SALT --project-name hu13102++```103++104++`INBOX_SALT` is a random string (for example `openssl rand -hex 32`).105++The function hashes `v1|<ip>|<yyyy-mm-dd>|<salt>` with SHA-256; no106++plaintext IP is stored. Rotating the secret unlinks old hashes.107++108++Optional variables: `INBOX_RATE_PER_HOUR` (default 2),109++`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300),110++`INBOX_MAX_BODY` (20000).111++112++## 3. Deploy113++114++```sh115++npx wrangler pages deploy dist --project-name hu13116++```117++118++Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json`119++limits Functions to `/inbox/*`, so every other request is static.120++Keep `wrangler.toml` at the repo root for the R2 binding.121++122++## What the Function implements123++124++`functions/inbox/[kind].js` serves both mailboxes:125++126++- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`,127++  `body` accepted as an alias)128++- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 400129++- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤100130++- license checkbox required; filled honeypot returns a fake receipt131++  without storing132++- same-origin `Origin`/`Referer` gate; no input is echoed or logged133++- fixed ~500 ms response delay; `Cache-Control: no-store` and134++  `X-Content-Type-Options: nosniff`135++- one R2 object per accepted submission:136++  `<uuid>.json` with `type/title/body/handle/contact/license_version/137++  received_at/salted_ip_hash` (same shape as the Docker inbox)138++139++Rate limits are layered: the Function has a best-effort isolate-local140++bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting141++rule for `/inbox/*` (for example 10 requests/minute per IP) as the142++durable control. The global quota is checked against the R2 bucket143++before storing; R2 list consistency means a burst can briefly exceed it.144++145++## Reading submissions146++147++```sh148++npx wrangler r2 object list hu13-inbox149++npx wrangler r2 object get hu13-inbox/<uuid>.json150++```151++152++`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`;153++it does not read R2. Copy objects down if you want the terminal reader.154++155++## Limits and caveats156++157++- Pages Free: 20,000 files and 25 MiB per file per deployment. The158++  build enforces both; large assets belong outside git or in Git LFS.159++- The function bundle must be deployed with `functions/`; uploading only160++  `dist/` (drag-and-drop or a plain static host) gives a dead inbox.161++- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no162++  smart server. No GitHub dependency.163++- Committed `/raw/*` bytes always download (`Content-Disposition:164++  attachment`) on both `serve` and Pages.165+diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js166+new file mode 100644167+index 0000000..d7592b8168+--- /dev/null169++++ b/functions/inbox/[kind].js170+@@ -0,0 +1,349 @@171++// Cloudflare Pages Functions inbox (f0rge #79).172++//173++// One dynamic route serves both mailboxes: /inbox/issue and174++// /inbox/pr-prompt. Contract parity with `hu13 serve`:175++//   urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected,176++//   controls stripped, title/body caps, license required,177++//   same-origin gate, fixed delay, ack uuid, no echo.178++// Storage is one JSON object per submission in the R2 binding INBOX.179++//180++// Bindings (Pages project settings / wrangler.toml):181++//   INBOX       R2 bucket182++//   INBOX_SALT  secret (required; SHA-256 salt for IP hashes)183++// Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500).184++185++const MAX_BYTES = 32 * 1024;186++const MAX_TITLE = 300;187++const MAX_BODY = 20000;188++const MAX_HANDLE = 40;189++const MAX_CONTACT = 100;190++const LICENSE_VERSION = "v1";191++const ACK_DELAY_MS = 500;192++193++const encoder = new TextEncoder();194++195++function esc(s) {196++  return String(s).replace(197++    /[&<>"]/g,198++    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],199++  );200++}201++202++function page(title, body) {203++  return `<!DOCTYPE html>204++<html lang="en"><head><meta charset="utf-8">205++<meta name="viewport" content="width=device-width, initial-scale=1">206++<meta name="robots" content="noindex">207++<title>${esc(title)}</title></head>208++<body><main><h1>${esc(title)}</h1>209++${body}210++<p><a href="/">hu13</a></p></main></body></html>211++`;212++}213++214++function answer(status, body, extra = {}, delay = true) {215++  const headers = {216++    "content-type": "text/html; charset=utf-8",217++    "x-content-type-options": "nosniff",218++    "cache-control": "no-store",219++    ...extra,220++  };221++  const response = () => new Response(body, { status, headers });222++  if (!delay) return response();223++  return new Promise((resolve) =>224++    setTimeout(() => resolve(response()), ACK_DELAY_MS),225++  );226++}227++228++function errorPage(status, detail, extra = {}) {229++  const title = status === 405 ? "method not allowed" : status === 429230++    ? "too many requests"231++    : status === 503232++    ? "unavailable"233++    : status === 413234++    ? "too large"235++    : "bad request";236++  return answer(237++    status,238++    page(title, `<p>${esc(detail)}</p>`),239++    extra,240++  );241++}242++243++function ackPage(uuid) {244++  return page(245++    "received",246++    `<p>ack: <code>${esc(uuid)}</code></p>247++<p>Thank you. This identifier was shown once and is not a lookup URL.</p>`,248++  );249++}250++251++function sleep(ms) {252++  return new Promise((resolve) => setTimeout(resolve, ms));253++}254++255++// Same-origin gate: browsers send Origin/Referer; agents (curl) send256++// neither and pass. `null`/unparsable/mismatched hosts fail closed.257++function originAllowed(request) {258++  let host;259++  try {260++    host = new URL(request.url).host.toLowerCase();261++  } catch {262++    return false;263++  }264++  for (const name of ["origin", "referer"]) {265++    const value = request.headers.get(name);266++    if (value === null) continue;267++    if (!value || value.trim().toLowerCase() === "null") return false;268++    try {269++      if (new URL(value).host.toLowerCase() !== host) return false;270++    } catch {271++      return false;272++    }273++  }274++  return true;275++}276++277++function hexByte(b) {278++  if (b >= 0x30 && b <= 0x39) return b - 0x30;279++  if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10;280++  if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10;281++  return -1;282++}283++284++// Strict percent-decoder matching hu13's `parse_form`: malformed `%`,285++// non-UTF-8, or a bad escape returns null (400) instead of guessing.286++function decodeComponent(input) {287++  const raw = encoder.encode(input);288++  const out = [];289++  let i = 0;290++  while (i < raw.length) {291++    let b = raw[i++];292++    if (b === 0x25) {293++      if (i + 1 >= raw.length) return null;294++      const hi = hexByte(raw[i++]);295++      const lo = hexByte(raw[i++]);296++      if (hi < 0 || lo < 0) return null;297++      b = (hi << 4) | lo;298++    } else if (b === 0x2b) {299++      b = 0x20;300++    }301++    out.push(b);302++  }303++  try {304++    return new TextDecoder("utf-8", { fatal: true }).decode(305++      Uint8Array.from(out),306++    );307++  } catch {308++    return null;309++  }310++}311++312++function parseForm(body) {313++  if (body === "") return [];314++  const pairs = [];315++  for (const part of body.split("&")) {316++    const eq = part.indexOf("=");317++    const key = eq < 0 ? part : part.slice(0, eq);318++    const value = eq < 0 ? "" : part.slice(eq + 1);319++    const k = decodeComponent(key);320++    const v = decodeComponent(value);321++    if (k === null || v === null) return null;322++    pairs.push([k, v]);323++  }324++  return pairs;325++}326++327++function field(pairs, name) {328++  const hit = pairs.find(([k]) => k === name);329++  return hit ? hit[1] : undefined;330++}331++332++function stripControls(s) {333++  return [...s]334++    .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c))335++    .join("");336++}337++338++function licenseChecked(value) {339++  if (value === undefined) return false;340++  return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes(341++    value.trim().toLowerCase(),342++  );343++}344++345++function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) {346++  if (347++    (field(pairs, "website") || "") !== "" ||348++    (field(pairs, "honeypot") || "") !== ""349++  ) {350++    return { kind: "honeypot" };351++  }352++  for (const [k, v] of pairs) {353++    if (k.includes("\0") || v.includes("\0")) return { kind: "reject" };354++  }355++  const rawTitle = field(pairs, "title") || "";356++  const bodyName = kind === "pr-prompt" ? "prompt" : "body";357++  let rawBody = field(pairs, bodyName);358++  if (rawBody === undefined && kind === "pr-prompt") {359++    rawBody = field(pairs, "body");360++  }361++  rawBody = rawBody || "";362++  const title = stripControls(rawTitle);363++  const body = stripControls(rawBody);364++  const handle = stripControls(field(pairs, "handle") || "");365++  const contact = stripControls(field(pairs, "contact") || "");366++  if (title.trim() === "" || body.trim() === "") return { kind: "reject" };367++  if (368++    [...title].length > maxTitle ||369++    [...body].length > maxBody ||370++    [...handle].length > MAX_HANDLE ||371++    [...contact].length > MAX_CONTACT372++  ) {373++    return { kind: "reject" };374++  }375++  if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" };376++  return { kind: "accept", title, body, handle, contact };377++}378++379++async function readCapped(request) {380++  const declared = request.headers.get("content-length");381++  if (declared !== null) {382++    if (!/^\d+$/.test(declared.trim())) return { error: 400 };383++    if (Number(declared) > MAX_BYTES) return { error: 413 };384++  }385++  if (!request.body) return { text: "" };386++  const reader = request.body.getReader();387++  const chunks = [];388++  let total = 0;389++  for (;;) {390++    const { done, value } = await reader.read();391++    if (done) break;392++    total += value.byteLength;393++    if (total > MAX_BYTES) {394++      await reader.cancel();395++      return { error: 413 };396++    }397++    chunks.push(value);398++  }399++  const merged = new Uint8Array(total);400++  let at = 0;401++  for (const chunk of chunks) {402++    merged.set(chunk, at);403++    at += chunk.byteLength;404++  }405++  try {406++    return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) };407++  } catch {408++    return { error: 400 };409++  }410++}411++412++async function saltedIpHash(ip, salt) {413++  const date = new Date().toISOString().slice(0, 10);414++  const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`);415++  const digest = await crypto.subtle.digest("SHA-256", bytes);416++  return [...new Uint8Array(digest)]417++    .map((b) => b.toString(16).padStart(2, "0"))418++    .join("");419++}420++421++// Best-effort isolate-local limiter. Cloudflare's edge rate-limiting422++// rule for /inbox/* is the durable control; this still stops a single423++// hot isolate from storing unlimited submissions.424++const buckets = new Map();425++function rateAllowed(key, limit) {426++  const now = Date.now();427++  if (buckets.size > 10000) {428++    for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);429++  }430++  let b = buckets.get(key);431++  if (!b || b.reset <= now) {432++    b = { count: 0, reset: now + 3600_000 };433++    buckets.set(key, b);434++  }435++  if (b.count >= limit) return false;436++  b.count += 1;437++  return true;438++}439++440++async function overQuota(bucket, limit) {441++  const listed = await bucket.list({ limit: Math.min(limit, 1000) });442++  return listed.objects.length >= limit;443++}444++445++function nowRfc3339() {446++  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");447++}448++449++async function handlePost(context) {450++  const request = context.request;451++  const rawKind = context.params ? context.params.kind : "";452++  const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind;453++  if (kind !== "issue" && kind !== "pr-prompt") {454++    return errorPage(404, "Nothing here.", {}, false);455++  }456++  if (!originAllowed(request)) {457++    return errorPage(403, "Bad origin.");458++  }459++  const ctype = (request.headers.get("content-type") || "").toLowerCase();460++  if (!ctype.startsWith("application/x-www-form-urlencoded")) {461++    return errorPage(400, "This endpoint accepts urlencoded forms only.");462++  }463++  const read = await readCapped(request);464++  if (read.error) {465++    return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input.");466++  }467++  if (read.text.includes("\0")) return errorPage(400, "Bad input.");468++  const pairs = parseForm(read.text);469++  if (pairs === null) return errorPage(400, "Bad input.");470++  const checked = validate(kind, pairs,471++    context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE,472++    context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY);473++  if (checked.kind === "honeypot") {474++    return answer(200, ackPage(crypto.randomUUID()));475++  }476++  if (checked.kind === "reject") return errorPage(400, "Bad input.");477++478++  const env = context.env || {};479++  if (!env.INBOX) return errorPage(503, "Could not store that. Try later.");480++  if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later.");481++  const ip = request.headers.get("cf-connecting-ip") || "unknown";482++  const ipHash = await saltedIpHash(ip, env.INBOX_SALT);483++  const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2;484++  if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down.");485++  const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500;486++  try {487++    if (await overQuota(env.INBOX, quota)) {488++      return errorPage(503, "Inbox full. Try later.");489++    }490++    const uuid = crypto.randomUUID();491++    const doc = {492++      type: kind,493++      title: checked.title,494++      body: checked.body,495++      handle: checked.handle,496++      contact: checked.contact,497++      license_version: LICENSE_VERSION,498++      received_at: nowRfc3339(),499++      salted_ip_hash: ipHash,500++    };501++    await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n");502++    return answer(200, ackPage(uuid));503++  } catch {504++    return errorPage(503, "Could not store that. Try later.");505++  }506++}507++508++export async function onRequestGet() {509++  return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false);510++}511++512++export async function onRequestPost(context) {513++  try {514++    return await handlePost(context);515++  } catch {516++    // Never echo or log submission text.517++    return errorPage(503, "Could not store that. Try later.");518++  }519++}520+diff --git a/src/build.rs b/src/build.rs521+index d6e3420..532802d 100644522+--- a/src/build.rs523++++ b/src/build.rs524+@@ -353,6 +353,12 @@ pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;525+ /// tried). Prevents a huge README from bloating every page's preview.526+ pub const README_MAX_BYTES: u64 = 1024 * 1024;527+ 528++/// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors529++/// every blob, so an ordinary asset-heavy repo can exceed these even530++/// when the build itself succeeds.531++pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024;532++pub const PAGES_MAX_FILES: usize = 20_000;533++534+ /// Bytes sniffed for the language-math binary check (first 8KiB).535+ pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;536+ 537+@@ -404,8 +410,25 @@ pub fn is_markdown(path: &str) -> bool {538+     lower.ends_with(".md") || lower.ends_with(".markdown")539+ }540+ 541++/// Percent-encode a repo path for a URL, then HTML-escape it. Git542++/// allows `#`, `?`, `%`, spaces and quotes in names; without this the543++/// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal544++/// so file paths remain hierarchical; everything outside the unreserved545++/// set becomes `%XX` of its UTF-8 bytes.546++fn encode_path(path: &str) -> String {547++    let mut out = String::with_capacity(path.len());548++    for &b in path.as_bytes() {549++        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {550++            out.push(char::from(b));551++        } else {552++            out.push_str(&format!("%{b:02X}"));553++        }554++    }555++    out556++}557++558+ fn href_esc(path: &str) -> String {559+-    esc(path)560++    esc(&encode_path(path))561+ }562+ 563+ /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars564+@@ -534,7 +557,20 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {565+     let y: i64 = d.next()?.parse().ok()?;566+     let mo: i64 = d.next()?.parse().ok()?;567+     let da: i64 = d.next()?.parse().ok()?;568+-    if !(1..=12).contains(&mo) || !(1..=31).contains(&da) {569++    // Keep the civil-date math overflow-free and refuse dates that never570++    // existed (Feb 31 used to normalize to Mar 3; huge years panicked).571++    if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) {572++        return None;573++    }574++    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;575++    let max_day = match mo {576++        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,577++        4 | 6 | 9 | 11 => 30,578++        2 if leap => 29,579++        2 => 28,580++        _ => return None,581++    };582++    if !(1..=max_day).contains(&da) {583+         return None;584+     }585+     let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {586+@@ -550,6 +586,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {587+         };588+         let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;589+         let om: i64 = rest[rest.len() - 2..].parse().ok()?;590++        if oh > 23 || om > 59 {591++            return None;592++        }593+         (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))594+     } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {595+         let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {596+@@ -559,6 +598,9 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {597+         };598+         let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;599+         let om: i64 = rest[rest.len() - 2..].parse().ok()?;600++        if oh > 23 || om > 59 {601++            return None;602++        }603+         (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))604+     } else {605+         (rest, 0)606+@@ -1019,13 +1061,18 @@ pub fn clone_block(607+ /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container608+ /// so long lines never break the 360px layout.609+ fn render_text_pre(display: &str, path: &str) -> String {610++    // Normalize line endings and drop exactly one trailing terminator:611++    // `split('\n')` used to turn a final newline into an empty L404 and612++    // kept `\r` in CRLF files (f0rge #88).613++    let normalized = display.replace("\r\n", "\n").replace('\r', "\n");614++    let text = normalized.strip_suffix('\n').unwrap_or(&normalized);615+     let mut out = String::from("<div class=\"pre-scroll\"><pre class=\"code\">\n");616+-    if display.is_empty() {617++    if text.is_empty() {618+         out.push_str("<span class=\"line\" id=\"L1\"><a class=\"ln\" href=\"#L1\">1</a> </span>");619+     } else {620+         // Build-time tokens (f0rge #73): unknown/huge files stay plain.621+-        let highlighted = crate::highlight::highlight_lines(path, display);622+-        for (i, line) in display.split('\n').enumerate() {623++        let highlighted = crate::highlight::highlight_lines(path, text);624++        for (i, line) in text.split('\n').enumerate() {625+             let n = i + 1;626+             out.push_str("<span class=\"line\" id=\"L");627+             out.push_str(&n.to_string());628+@@ -1076,7 +1123,7 @@ fn nav_bar(629+         tabs.push("<a href=\"/wiki/index.html\">wiki</a>".to_string());630+     }631+     tabs.push("<a href=\"/agents.html\">agents</a>".to_string());632+-    tabs.join(" · ")633++    tabs.join("<span class=\"nav-sep\" aria-hidden=\"true\">·</span>")634+ }635+ 636+ /// Write `out/style.css` from the embedded static file.637+@@ -1088,6 +1135,12 @@ fn write_style(out: &Path) -> Result<(), String> {638+ /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`639+ /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).640+ /// SVG-first, zero binary blobs: the header lockup is `logo.svg`641++/// Pages Functions routing: only `/inbox/*` runs a Function; every642++/// other path stays a static asset. Must live in the build output, not643++/// the repo root (f0rge #79).644++pub const ROUTES_FILE: &str =645++    "{\n  \"version\": 1,\n  \"include\": [\"/inbox/*\"],\n  \"exclude\": []\n}\n";646++647+ /// Header rules for Cloudflare Pages (and any host that reads `_headers`).648+ ///649+ /// `/raw/**` holds committed, untrusted bytes: a repo can contain650+@@ -1120,11 +1173,12 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {651+         .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;652+     std::fs::write(out.join("_headers"), HEADERS_FILE)653+         .map_err(|e| format!("hu13: write _headers: {e}"))?;654++    std::fs::write(out.join("_routes.json"), ROUTES_FILE)655++        .map_err(|e| format!("hu13: write _routes.json: {e}"))?;656+     Ok(())657+ }658+ 659+-/// Absolute lexical form of `p` (no symlink resolution beyond what the660+-/// OS already did for existing components).661++/// Absolute form of `p` (join cwd for relative paths, no symlink work).662+ fn absolute(p: &Path) -> std::path::PathBuf {663+     if p.is_absolute() {664+         p.to_path_buf()665+@@ -1135,15 +1189,81 @@ fn absolute(p: &Path) -> std::path::PathBuf {666+     }667+ }668+ 669++/// Absolute lexical form of `p` with `.`/`..` collapsed. Does not670++/// touch the filesystem (unlike canonicalize), so it also works for an671++/// output path that does not exist yet.672++fn normalize_lexical(p: &Path) -> std::path::PathBuf {673++    use std::path::Component;674++    let mut out = std::path::PathBuf::new();675++    for c in p.components() {676++        match c {677++            Component::CurDir => {}678++            Component::ParentDir => {679++                out.pop();680++            }681++            Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {682++                out.push(c.as_os_str());683++            }684++        }685++    }686++    out687++}688++689++/// Resolve symlinks in the deepest existing ancestor, then append the690++/// not-yet-created suffix. This catches `-o alias/..` where `alias` is a691++/// symlink into the repo (f0rge #77).692++fn resolve_existing(p: &Path) -> std::path::PathBuf {693++    let abs = normalize_lexical(&absolute(p));694++    let mut suffix: Vec<std::ffi::OsString> = Vec::new();695++    let mut cur = abs.as_path();696++    loop {697++        if let Ok(mut resolved) = cur.canonicalize() {698++            for part in suffix.iter().rev() {699++                resolved.push(part);700++            }701++            return normalize_lexical(&resolved);702++        }703++        match cur.file_name() {704++            Some(name) => {705++                suffix.push(name.to_os_string());706++                match cur.parent() {707++                    Some(parent) => cur = parent,708++                    None => return abs,709++                }710++            }711++            None => return abs,712++        }713++    }714++}715++716++/// `true` when git tracks at least one path under repo-relative `rel`.717++fn has_tracked_under(repo: &Path, rel: &Path) -> bool {718++    if rel.as_os_str().is_empty() {719++        return true;720++    }721++    std::process::Command::new("git")722++        .arg("-C")723++        .arg(repo)724++        .arg("ls-files")725++        .arg("-z")726++        .arg("--")727++        .arg(rel)728++        .output()729++        .map(|o| o.status.success() && !o.stdout.is_empty())730++        .unwrap_or(true)731++}732++733+ /// Remove everything already in `out/` before a build (f0rge #67).734+ ///735+ /// Without this, pages for deleted issues/files keep being served from a736+ /// reused output dir. Refuses to delete the repo itself or anything that737+-/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir738+-/// without following it.739++/// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked740++/// ancestors before comparing, and refuses an in-repo output that holds741++/// tracked files (f0rge #77).742+ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {743+-    let out_abs = absolute(out);744+-    let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));745++    let out_path = normalize_lexical(&absolute(out));746++    let out_abs = resolve_existing(&out_path);747++    let repo_abs = resolve_existing(repo);748+     if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {749+         return Err(format!(750+             "hu13: refusing to clean output dir {}: it contains the repo",751+@@ -1153,25 +1273,114 @@ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {752+     if out_abs.parent().is_none() {753+         return Err("hu13: refusing to use the filesystem root as output dir".to_string());754+     }755+-    match std::fs::symlink_metadata(&out_abs) {756++    if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {757++        if rel758++            .components()759++            .next()760++            .is_some_and(|c| c.as_os_str() == ".git")761++            || has_tracked_under(&repo_abs, rel)762++        {763++            return Err(format!(764++                "hu13: refusing to clean in-repo output dir {}: it holds tracked files",765++                out.display()766++            ));767++        }768++    }769++    match std::fs::symlink_metadata(&out_path) {770+         Ok(meta) if meta.file_type().is_symlink() => {771+-            std::fs::remove_file(&out_abs)772++            std::fs::remove_file(&out_path)773+                 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;774+         }775+         Ok(meta) if meta.is_dir() => {776+-            std::fs::remove_dir_all(&out_abs)777++            std::fs::remove_dir_all(&out_path)778+                 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;779+         }780+         Ok(_) => {781+-            std::fs::remove_file(&out_abs)782++            std::fs::remove_file(&out_path)783+                 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;784+         }785+         Err(_) => {}786+     }787+-    std::fs::create_dir_all(&out_abs)788++    std::fs::create_dir_all(&out_path)789+         .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))790+ }791+ 792++/// Make `og:image` absolute on every built page when a public origin is793++/// configured (f0rge #95). Done once over the output tree because the794++/// origin is build config, not page content: all pages share the same795++/// `content="/og.png"` marker.796++fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {797++    let base = origin.trim_end_matches('/');798++    if base.is_empty() {799++        return Ok(());800++    }801++    let absolute = format!("content=\"{}/og.png\"", esc(base));802++    let mut stack = vec![out.to_path_buf()];803++    while let Some(dir) = stack.pop() {804++        let rd = std::fs::read_dir(&dir)805++            .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;806++        for ent in rd {807++            let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;808++            let path = ent.path();809++            if path.is_dir() {810++                stack.push(path);811++            } else if path.extension().is_some_and(|x| x == "html") {812++                let html = std::fs::read_to_string(&path)813++                    .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;814++                let replaced = html.replace("content=\"/og.png\"", &absolute);815++                if replaced != html {816++                    std::fs::write(&path, replaced)817++                        .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;818++                }819++            }820++        }821++    }822++    Ok(())823++}824++825++/// Check an output tree against deployment limits. Returns the first826++/// offending file (or the file-count overflow) with enough context to827++/// fix the repo. Kept separate from the walk so tests can use tiny caps.828++pub fn check_output_budget(829++    out: &Path,830++    max_file_bytes: u64,831++    max_files: usize,832++) -> Result<(), String> {833++    let mut stack = vec![out.to_path_buf()];834++    let mut files = 0usize;835++    while let Some(dir) = stack.pop() {836++        let rd = std::fs::read_dir(&dir)837++            .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;838++        for ent in rd {839++            let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;840++            let path = ent.path();841++            let meta = ent842++                .metadata()843++                .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;844++            if meta.is_dir() {845++                stack.push(path);846++            } else if meta.is_file() {847++                files += 1;848++                if files > max_files {849++                    return Err(format!(850++                        "hu13: output has more than {max_files} files (Cloudflare Pages limit);                          last: {}",851++                        path.display()852++                    ));853++                }854++                if meta.len() > max_file_bytes {855++                    return Err(format!(856++                        "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes;                          keep large assets out of git or use Git LFS",857++                        path.display(),858++                        meta.len(),859++                        max_file_bytes860++                    ));861++                }862++            }863++        }864++    }865++    Ok(())866++}867++868+ /// Build the full static site. Returns a summary on success.869+ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {870+     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())871+@@ -1308,7 +1517,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu872+             format!(".. ({parent})")873+         };874+         let mut body = breadcrumb(dir);875+-        body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));876++        body.push_str(&format!("<h1>{}/</h1>\n", esc(dir)));877+         body.push_str(&file_table_html(878+             dir,879+             &entries,880+@@ -1407,7 +1616,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu881+         &short_sha,882+         desc_opt,883+     )?;884+-    feed::render_loaded(repo, out, &branch, &name, &commit_log, desc_opt)?;885++    feed::render_loaded_with_origin(886++        repo,887++        out,888++        &branch,889++        &name,890++        &commit_log,891++        desc_opt,892++        public_origin.as_deref(),893++    )?;894+     let wiki_pages = wiki::render(repo, out, &name, &nav, &branch, &short_sha, desc_opt)?;895+     // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.896+     // Tasks/releases/wiki indexes always render even when empty (m6897+@@ -1511,6 +1728,28 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu898+     std::fs::write(out.join("index.html"), index)899+         .map_err(|e| format!("hu13: write index.html: {e}"))?;900+ 901++    // Branded 404 for Pages/nginx; `serve` streams this file with status902++    // 404 on unknown paths (f0rge #85). Same nav/footer as every page.903++    let not_found = crate::layout::page_site_with_provenance(904++        &format!("not found — {name}"),905++        &nav,906++        "<h1>404</h1>\n<p>Nothing here.</p>\n<p><a href=\"/\">Back to code</a></p>\n",907++        &name,908++        desc_opt,909++        &branch,910++        &short_sha,911++    );912++    std::fs::write(out.join("404.html"), not_found)913++        .map_err(|e| format!("hu13: write 404.html: {e}"))?;914++915++    if let Some(origin) = public_origin.as_deref() {916++        absolutize_social_meta(out, origin)?;917++    }918++919++    // Fail loudly here instead of letting Pages reject an opaque deploy920++    // (f0rge #81). Counts the whole emitted tree, including repo.git.921++    check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?;922++923+     Ok(BuildSummary {924+         branch,925+         short_sha,926+@@ -2235,6 +2474,33 @@ mod tests {927+         assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");928+     }929+ 930++    #[test]931++    fn output_budget_reports_file_size_and_count() {932++        let dir = mktemp("budget");933++        std::fs::write(dir.join("small.txt"), "ok").unwrap();934++        std::fs::create_dir_all(dir.join("nested")).unwrap();935++        std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap();936++        assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap");937++        assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap");938++        assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps");939++        std::fs::remove_dir_all(&dir).unwrap();940++    }941++942++    #[test]943++    fn hostile_dates_are_rejected() {944++        // Hostile `.forge` timestamps must not panic (f0rge #82).945++        assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none());946++        assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none());947++        assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none());948++        assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none());949++        assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none());950++        assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none());951++        assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none());952++        assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some());953++        assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none());954++        assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none());955++    }956++957+     #[test]958+     fn relative_age_fixed_clock() {959+         // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.960+diff --git a/src/feed.rs b/src/feed.rs961+index c6b99b6..24be85d 100644962+--- a/src/feed.rs963++++ b/src/feed.rs964+@@ -143,12 +143,31 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result<Vec<F965+ /// when present, becomes the feed `<subtitle>` (escaped; hostile markup966+ /// renders as text). `None` omits `<subtitle>` entirely.967+ #[must_use]968++#[allow(dead_code)]969+ pub fn render_feed(970+     site_name: &str,971+     branch: &str,972+     entries: &[FeedEntry],973+     description: Option<&str>,974+ ) -> String {975++    render_feed_with_origin(site_name, branch, entries, description, None)976++}977++978++/// [`render_feed`] with absolute entry links (f0rge #96): Atom readers979++/// disagree on relative IRIs, so prefix `/...` links with the public980++/// origin when one is configured. Already-absolute links stay untouched.981++#[must_use]982++pub fn render_feed_with_origin(983++    site_name: &str,984++    branch: &str,985++    entries: &[FeedEntry],986++    description: Option<&str>,987++    public_origin: Option<&str>,988++) -> String {989++    let origin = public_origin990++        .map(str::trim)991++        .map(|s| s.trim_end_matches('/'))992++        .filter(|s| !s.is_empty());993+     let updated = entries994+         .first()995+         .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated));996+@@ -161,12 +180,22 @@ pub fn render_feed(997+     out.push_str(&format!("<id>urn:hu13:feed:{}</id>\n", esc(branch)));998+     out.push_str(&format!("<updated>{updated}</updated>\n"));999+     out.push_str("<author><name>hu13</name></author>\n");1000++    if let Some(base) = origin {1001++        out.push_str(&format!(1002++            "<link rel=\"self\" href=\"{}/feed.atom\"/>\n",1003++            esc(base)1004++        ));1005++    }1006+     for e in entries {1007+         out.push_str("<entry>\n");1008+         out.push_str(&format!("<title>{}</title>\n", esc(&e.title)));1009+         out.push_str(&format!("<id>{}</id>\n", esc(&e.id)));1010+         out.push_str(&format!("<updated>{}</updated>\n", esc(&e.updated)));1011+-        out.push_str(&format!("<link href=\"{}\"/>\n", esc(&e.link)));1012++        let link = match origin {1013++            Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link),1014++            _ => e.link.clone(),1015++        };1016++        out.push_str(&format!("<link href=\"{}\"/>\n", esc(&link)));1017+         out.push_str(&format!("<summary>{}</summary>\n", esc(&e.summary)));1018+         out.push_str("</entry>\n");1019+     }1020+@@ -185,10 +214,11 @@ pub fn render(1021+     description: Option<&str>,1022+ ) -> Result<usize, String> {1023+     let entries = collect_entries(repo, branch)?;1024+-    render_entries(out, site_name, branch, &entries, description)1025++    render_entries(out, site_name, branch, &entries, description, None)1026+ }1027+ 1028+ /// Build `feed.atom` from already-loaded commits (one struct pass).1029++#[allow(dead_code)]1030+ pub fn render_loaded(1031+     repo: &Path,1032+     out: &Path,1033+@@ -196,9 +226,23 @@ pub fn render_loaded(1034+     site_name: &str,1035+     log: &[gitx::Commit],1036+     description: Option<&str>,1037++) -> Result<usize, String> {1038++    render_loaded_with_origin(repo, out, branch, site_name, log, description, None)1039++}1040++1041++/// [`render_loaded`] with absolute links for a configured public origin.1042++#[allow(clippy::too_many_arguments)]1043++pub fn render_loaded_with_origin(1044++    repo: &Path,1045++    out: &Path,1046++    branch: &str,1047++    site_name: &str,1048++    log: &[gitx::Commit],1049++    description: Option<&str>,1050++    public_origin: Option<&str>,1051+ ) -> Result<usize, String> {1052+     let entries = collect_entries_loaded(repo, log)?;1053+-    render_entries(out, site_name, branch, &entries, description)1054++    render_entries(out, site_name, branch, &entries, description, public_origin)1055+ }1056+ 1057+ fn render_entries(1058+@@ -207,8 +251,9 @@ fn render_entries(1059+     branch: &str,1060+     entries: &[FeedEntry],1061+     description: Option<&str>,1062++    public_origin: Option<&str>,1063+ ) -> Result<usize, String> {1064+-    let doc = render_feed(site_name, branch, entries, description);1065++    let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin);1066+     std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?;1067+     std::fs::write(out.join("feed.atom"), doc)1068+         .map_err(|e| format!("hu13: write feed.atom: {e}"))?;1069+diff --git a/src/forge.rs b/src/forge.rs1070+index a9ada13..c9f7ce6 1006441071+--- a/src/forge.rs1072++++ b/src/forge.rs1073+@@ -765,6 +765,26 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {1074+     }1075+ }1076+ 1077++/// Remove builder-local absolute paths from published CI logs (f0rge1078++/// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace1079++/// the canonical repo root with `<repo>`, then `$HOME` with `~`.1080++fn redact_paths(text: &str, root: &Path) -> String {1081++    let mut out = text.to_string();1082++    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());1083++    for p in [root_abs.as_path(), root] {1084++        let needle = p.to_string_lossy();1085++        if !needle.is_empty() && needle != "/" {1086++            out = out.replace(needle.as_ref(), "<repo>");1087++        }1088++    }1089++    if let Ok(home) = std::env::var("HOME") {1090++        if !home.is_empty() && home != "/" {1091++            out = out.replace(&home, "~");1092++        }1093++    }1094++    out1095++}1096++1097+ fn truncate_log(text: &str) -> String {1098+     if text.len() <= LOG_PREVIEW_MAX {1099+         return text.to_string();1100+@@ -778,6 +798,8 @@ fn truncate_log(text: &str) -> String {1101+ 1102+ fn load_ci(root: &Path) -> CiInfo {1103+     let dir = root.join("ci");1104++    // `root` is `<repo>/.forge`; logs name the repo checkout itself.1105++    let repo_root = root.parent().unwrap_or(root);1106+     let runner = if dir.join("ci.env").exists() {1107+         match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {1108+             Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {1109+@@ -817,7 +839,8 @@ fn load_ci(root: &Path) -> CiInfo {1110+             }1111+             let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))1112+                 .unwrap_or_default();1113+-            logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));1114++            let text = String::from_utf8_lossy(&bytes);1115++            logs.push((name, truncate_log(&redact_paths(&text, repo_root))));1116+         }1117+         Some(CiRun {1118+             run: fm.run,1119+@@ -832,7 +855,7 @@ fn load_ci(root: &Path) -> CiInfo {1120+                     duration_ms: s.duration_ms,1121+                 })1122+                 .collect(),1123+-            body: body.to_string(),1124++            body: redact_paths(body, repo_root),1125+             logs,1126+         })1127+     });1128+@@ -1142,14 +1165,19 @@ fn render_issues_index(1129+         // non-open (newest-first within). Numbers stay creation-rank.1130+         // m6/cards2 (#46 + #47): title-first column order.1131+         for issue in data.issues_in_display_order() {1132++            let state_plain = esc(&issue.state);1133++            let state_html = state_pill(&issue.state);1134++            let created_plain = time_ago(&issue.created, now);1135+             body.push_str(&format!(1136+-                "<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",1137++                "<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",1138+                 n = issue.number,1139+                 id = esc(&issue.id),1140+                 title = esc(&issue.title),1141+-                state = state_pill(&issue.state),1142++                state_plain = state_plain,1143++                state_html = state_html,1144+                 comments = issue.comments.len(),1145+-                created = time_ago(&issue.created, now),1146++                created = esc(&created_plain),1147++                created_html = created_plain,1148+             ));1149+         }1150+         body.push_str("</tbody>\n</table></div>\n");1151+@@ -1207,19 +1235,28 @@ fn render_pulls_index(1152+         // Numbers stay creation-rank.1153+         // m6/cards2 (#46 + #47): title-first column order.1154+         for pr in data.prs_in_display_order() {1155+-            let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1156++            let lanes = format!(1157++                "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1158++                esc(&pr.head),1159++                esc(&pr.base)1160++            );1161+             let lanes_plain = format!("{}→{}", pr.head, pr.base);1162+             let draft = if pr.draft { " draft" } else { "" };1163+             let full_title = format!("{}{}", pr.title, draft);1164++            let state_plain = esc(&pr.state);1165++            let state_html = state_pill(&pr.state);1166++            let created_plain = time_ago(&pr.created, now);1167+             body.push_str(&format!(1168+-                "<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",1169++                "<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",1170+                 n = pr.number,1171+                 id = esc(&pr.id),1172+                 title = esc(&pr.title),1173+                 full = esc(&full_title),1174+-                state = state_pill(&pr.state),1175++                state_plain = state_plain,1176++                state_html = state_html,1177+                 lanes_title = esc(&lanes_plain),1178+-                created = time_ago(&pr.created, now),1179++                created = esc(&created_plain),1180++                created_html = created_plain,1181+             ));1182+         }1183+         body.push_str("</tbody>\n</table></div>\n");1184+diff --git a/src/highlight.rs b/src/highlight.rs1185+index 5a79051..65c885d 1006441186+--- a/src/highlight.rs1187++++ b/src/highlight.rs1188+@@ -235,48 +235,74 @@ pub fn highlight_lines(path: &str, text: &str) -> Option<Vec<String>> {1189+     Some(out)1190+ }1191+ 1192++fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool {1193++    for (i, c) in (at..).zip(pat.chars()) {1194++        if chars.get(i) != Some(&c) {1195++            return false;1196++        }1197++    }1198++    true1199++}1200++1201++/// Find `pat` at or after char index `from`, without building a1202++/// `String` for the rest of the line (the old scanner did that once per1203++/// character, which made minified files quadratic; f0rge #78).1204++fn find_at(chars: &[char], from: usize, pat: &str) -> Option<usize> {1205++    let len = pat.chars().count();1206++    if len == 0 {1207++        return Some(from);1208++    }1209++    let last = chars.len().checked_sub(len)?;1210++    if from > last {1211++        return None;1212++    }1213++    (from..=last).find(|&i| starts_with_at(chars, i, pat))1214++}1215++1216++fn slice(chars: &[char], from: usize, to: usize) -> String {1217++    chars[from..to].iter().collect()1218++}1219++1220+ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1221+     let chars: Vec<char> = line.chars().collect();1222+     let mut out = String::with_capacity(line.len() + 16);1223+     let mut i = 0;1224+     while i < chars.len() {1225+-        let rest: String = chars[i..].iter().collect();1226+-1227+         if *in_block {1228+             if let Some((_, end)) = lang.block {1229+-                if let Some(pos) = rest.find(end) {1230+-                    let stop = i + rest[..pos].chars().count() + end.chars().count();1231+-                    let piece: String = chars[i..stop].iter().collect();1232+-                    push_span(&mut out, "com", &piece);1233++                if let Some(end_at) = find_at(&chars, i, end) {1234++                    let stop = end_at + end.chars().count();1235++                    push_span(&mut out, "com", &slice(&chars, i, stop));1236+                     *in_block = false;1237+                     i = stop;1238+                     continue;1239+                 }1240+             }1241+-            let piece: String = chars[i..].iter().collect();1242+-            push_span(&mut out, "com", &piece);1243++            push_span(&mut out, "com", &slice(&chars, i, chars.len()));1244+             return out;1245+         }1246+ 1247+         if let Some((start, end)) = lang.block {1248+-            if let Some(after_start) = rest.strip_prefix(start) {1249+-                if let Some(pos) = after_start.find(end) {1250+-                    let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count();1251+-                    let piece: String = chars[i..stop].iter().collect();1252+-                    push_span(&mut out, "com", &piece);1253++            if starts_with_at(&chars, i, start) {1254++                let after_start = i + start.chars().count();1255++                if let Some(end_at) = find_at(&chars, after_start, end) {1256++                    let stop = end_at + end.chars().count();1257++                    push_span(&mut out, "com", &slice(&chars, i, stop));1258+                     i = stop;1259+                     continue;1260+                 }1261+                 *in_block = true;1262+-                let piece: String = chars[i..].iter().collect();1263+-                push_span(&mut out, "com", &piece);1264++                push_span(&mut out, "com", &slice(&chars, i, chars.len()));1265+                 return out;1266+             }1267+         }1268+ 1269+-        if lang.line_comment.iter().any(|m| rest.starts_with(m)) {1270+-            let piece: String = chars[i..].iter().collect();1271+-            push_span(&mut out, "com", &piece);1272++        if lang1273++            .line_comment1274++            .iter()1275++            .any(|m| starts_with_at(&chars, i, m))1276++        {1277++            push_span(&mut out, "com", &slice(&chars, i, chars.len()));1278+             return out;1279+         }1280+ 1281+@@ -296,8 +322,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1282+                 }1283+                 j += 1;1284+             }1285+-            let piece: String = chars[i..j].iter().collect();1286+-            push_span(&mut out, "str", &piece);1287++            push_span(&mut out, "str", &slice(&chars, i, j));1288+             i = j;1289+             continue;1290+         }1291+@@ -309,8 +334,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1292+             {1293+                 j += 1;1294+             }1295+-            let piece: String = chars[i..j].iter().collect();1296+-            push_span(&mut out, "num", &piece);1297++            push_span(&mut out, "num", &slice(&chars, i, j));1298+             i = j;1299+             continue;1300+         }1301+@@ -320,7 +344,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String {1302+             while j < chars.len() && is_ident(chars[j]) {1303+                 j += 1;1304+             }1305+-            let word: String = chars[i..j].iter().collect();1306++            let word = slice(&chars, i, j);1307+             let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace());1308+             let call = next_non_space.is_some_and(|c| *c == '(');1309+             let class = if lang.keywords.contains(&word.as_str()) {1310+@@ -395,6 +419,17 @@ mod tests {1311+         assert!(lines[0].contains("&lt;script&gt;"));1312+     }1313+ 1314++    #[test]1315++    fn long_single_line_is_not_quadratic() {1316++        // 64 KiB on one line took ~27s with the per-char suffix clone;1317++        // the linear scanner stays far below this coarse bound (f0rge #78).1318++        let big = "var x=1;".repeat(8000);1319++        let start = std::time::Instant::now();1320++        let lines = highlight_lines("min.js", &big).expect("js");1321++        assert!(start.elapsed() < std::time::Duration::from_secs(5));1322++        assert!(!lines[0].is_empty());1323++    }1324++1325+     #[test]1326+     fn oversized_inputs_stay_plain() {1327+         let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1);1328+diff --git a/src/layout.rs b/src/layout.rs1329+index 20480b7..b2e29e5 1006441330+--- a/src/layout.rs1331++++ b/src/layout.rs1332+@@ -57,10 +57,13 @@ pub fn diff_html(raw: &str) -> String {1333+         let path = diff_chunk_path(chunk);1334+         let mut added = 0usize;1335+         let mut removed = 0usize;1336++        let mut counting_hunk = false;1337+         for line in chunk {1338+-            if line.starts_with('+') && !line.starts_with("+++") {1339++            if line.starts_with("@@") {1340++                counting_hunk = true;1341++            } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) {1342+                 added += 1;1343+-            } else if line.starts_with('-') && !line.starts_with("---") {1344++            } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) {1345+                 removed += 1;1346+             }1347+         }1348+@@ -82,13 +85,10 @@ pub fn diff_html(raw: &str) -> String {1349+ 1350+         let mut old_ln = 0i64;1351+         let mut new_ln = 0i64;1352+-        let mut first = true;1353++        let mut in_hunk = false;1354+         for line in chunk {1355+-            if !first {1356+-                out.push('\n');1357+-            }1358+-            first = false;1359+             if line.starts_with("@@") {1360++                in_hunk = true;1361+                 let mut parts = line.split_whitespace();1362+                 let _ = parts.next();1363+                 if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {1364+@@ -106,10 +106,22 @@ pub fn diff_html(raw: &str) -> String {1365+                         .unwrap_or(0);1366+                 }1367+                 out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));1368+-            } else if line.starts_with('+') && !line.starts_with("+++") {1369++            } else if line.starts_with("+++") || line.starts_with("---") {1370++                if in_hunk {1371++                    if line.starts_with('+') {1372++                        out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));1373++                        new_ln += 1;1374++                    } else {1375++                        out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));1376++                        old_ln += 1;1377++                    }1378++                } else {1379++                    out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));1380++                }1381++            } else if line.starts_with('+') {1382+                 out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));1383+                 new_ln += 1;1384+-            } else if line.starts_with('-') && !line.starts_with("---") {1385++            } else if line.starts_with('-') {1386+                 out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));1387+                 old_ln += 1;1388+             } else if line.starts_with(' ') {1389+@@ -121,8 +133,6 @@ pub fn diff_html(raw: &str) -> String {1390+                 ));1391+                 old_ln += 1;1392+                 new_ln += 1;1393+-            } else if line.starts_with("+++") || line.starts_with("---") {1394+-                out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));1395+             } else {1396+                 out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));1397+             }1398+@@ -142,14 +152,15 @@ fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {1399+ /// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the1400+ /// `diff --git` header. `/dev/null` falls through to the other side.1401+ fn diff_chunk_path(lines: &[&str]) -> String {1402+-    for line in lines {1403++    let headers = lines.iter().take_while(|l| !l.starts_with("@@"));1404++    for line in headers.clone() {1405+         if let Some(rest) = line.strip_prefix("+++ ") {1406+             if rest != "/dev/null" {1407+                 return rest.strip_prefix("b/").unwrap_or(rest).to_string();1408+             }1409+         }1410+     }1411+-    for line in lines {1412++    for line in headers {1413+         if let Some(rest) = line.strip_prefix("--- ") {1414+             if rest != "/dev/null" {1415+                 return rest.strip_prefix("a/").unwrap_or(rest).to_string();1416+@@ -168,16 +179,12 @@ fn diff_chunk_path(lines: &[&str]) -> String {1417+ /// with line-level coloring (markers stay span-free, old behavior).1418+ fn plain_diff_html(raw: &str) -> String {1419+     let mut out = String::with_capacity(raw.len() + raw.len() / 4);1420+-    let mut first = true;1421+     for line in raw.lines() {1422+-        if !first {1423+-            out.push('\n');1424+-        }1425+-        first = false;1426++        // One block span per line; no literal newlines between spans or1427++        // `white-space:pre` inserts a blank row (f0rge #86).1428++        out.push_str("<span class=\"dline\">");1429+         out.push_str(&diff_line_span(line));1430+-    }1431+-    if raw.ends_with('\n') {1432+-        out.push('\n');1433++        out.push_str("</span>");1434+     }1435+     out1436+ }1437+@@ -322,7 +329,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin1438+         }1439+     }1440+     format!(1441+-        "<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>"1442++        "<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>"1443+     )1444+ }1445+ 1446+@@ -644,6 +651,42 @@ mod tests {1447+         assert!(!html.contains("<span> line one"), "context has no span");1448+     }1449+ 1450++    #[test]1451++    fn diff_rows_have_no_blank_lines_and_content_headers_count() {1452++        // f0rge #86: no literal newline between block rows.1453++        // f0rge #87: `++++` after a hunk is added content, not a header.1454++        let raw = "diff --git a/a.txt b/a.txt\n\1455++--- a/a.txt\n+++ b/a.txt\n\1456++@@ -1 +1,4 @@\n context\n\1457++++++ not a header\n+--- not a header\n----- removed content\n+plain\n";1458++        let html = diff_html(raw);1459++        assert!(1460++            !html.contains("</span>\n<span class=\"df-line\""),1461++            "no newline between diff rows: {html}"1462++        );1463++        assert!(1464++            html.contains("<span class=\"df-add\">+3</span>"),1465++            "three added lines, including +++ content: {html}"1466++        );1467++        assert!(1468++            html.contains("<span class=\"df-del\">−1</span>"),1469++            "one removed line, including --- content: {html}"1470++        );1471++        assert!(1472++            html.contains("<span class=\"diff-add\">++++ not a header</span>"),1473++            "added +++ content keeps its marker and is not a file header: {html}"1474++        );1475++        assert!(1476++            html.contains("<span class=\"diff-del\">----- removed content</span>"),1477++            "removed --- content is a removal: {html}"1478++        );1479++        assert_eq!(1480++            html.matches("<span class=\"diff-file\">").count(),1481++            2,1482++            "only the real ---/+++ file headers"1483++        );1484++    }1485++1486+     #[test]1487+     fn diff_html_escapes_before_wrapping() {1488+         let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");1489+diff --git a/src/serve.rs b/src/serve.rs1490+index 12718d1..85df371 1006441491+--- a/src/serve.rs1492++++ b/src/serve.rs1493+@@ -485,7 +485,9 @@ const RAW_ACTIVE_EXTS: &[&str] = &[1494+     "pdf", "swf", "hta", "jar", "crx", "xpi",1495+ ];1496+ 1497+-/// Text-ish extensions safe to show inline from `/raw/`.1498++/// Text-ish extensions that keep a text MIME type. They still download:1499++/// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses1500++/// the same disposition instead of rendering text inline (f0rge #93).1501+ const RAW_TEXT_EXTS: &[&str] = &[1502+     "txt",1503+     "text",1504+@@ -550,14 +552,14 @@ fn extension(name: &str) -> String {1505+ ///1506+ /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run1507+ /// script in the site's origin (where the inbox endpoints live). Active1508+-/// types become an octet-stream download; text stays inline as1509+-/// `text/plain`; everything else downloads.1510++/// types become an octet-stream download; text keeps `text/plain` but1511++/// also downloads; everything downloads with `attachment` (f0rge #93).1512+ fn raw_content_type(name: &str) -> (&'static str, &'static str) {1513+     let ext = extension(name);1514+     if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {1515+         ("application/octet-stream", "attachment")1516+     } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {1517+-        ("text/plain; charset=utf-8", "inline")1518++        ("text/plain; charset=utf-8", "attachment")1519+     } else {1520+         ("application/octet-stream", "attachment")1521+     }1522+@@ -706,13 +708,17 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {1523+     };1524+     let dest = absolutize(&shared.www.join(&file_rel));1525+     if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {1526+-        static_error(&mut stream, 404);1527++        static_not_found(&mut stream, &head, shared);1528+         return;1529+     }1530+-    let bytes = match std::fs::read(&dest) {1531+-        Ok(b) if dest.is_file() => b,1532++    // Stat, then stream. Never read a static file into memory: a raw1533++    // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The1534++    // method check stays after existence so an unknown POST is still1535++    // 404 (fail closed, no route discovery).1536++    let meta = match std::fs::metadata(&dest) {1537++        Ok(m) if m.is_file() => m,1538+         _ => {1539+-            static_error(&mut stream, 404);1540++            static_not_found(&mut stream, &head, shared);1541+             return;1542+         }1543+     };1544+@@ -732,22 +738,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {1545+         &[]1546+     };1547+     if head.method == "HEAD" {1548+-        let mut out = format!(1549+-            "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",1550+-            bytes.len()1551+-        );1552+-        for (k, v) in extra {1553+-            out.push_str(k);1554+-            out.push_str(": ");1555+-            out.push_str(v);1556+-            out.push_str("\r\n");1557+-        }1558+-        out.push_str("\r\n");1559+-        let _ = stream.write_all(out.as_bytes());1560+-        let _ = stream.flush();1561++        respond_file_head(&mut stream, 200, ctype, extra, meta.len());1562+         return;1563+     }1564+-    respond(&mut stream, 200, ctype, extra, &bytes);1565++    respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());1566+ }1567+ 1568+ /// Read-and-discard in-flight request bytes (bounded) so early rejects1569+@@ -800,13 +794,12 @@ impl Drop for GitSlot {1570+     }1571+ }1572+ 1573+-/// Stream a file body with `Content-Length` (no full-pack buffering).1574+-fn respond_file(1575++/// Headers for a file response, without opening the file body.1576++fn respond_file_head(1577+     stream: &mut TcpStream,1578+     code: u16,1579+     content_type: &str,1580+     extra: &[(&str, &str)],1581+-    path: &Path,1582+     len: u64,1583+ ) {1584+     let mut out = format!(1585+@@ -820,9 +813,51 @@ fn respond_file(1586+         out.push_str("\r\n");1587+     }1588+     out.push_str("\r\n");1589+-    if stream.write_all(out.as_bytes()).is_err() {1590++    let _ = stream.write_all(out.as_bytes());1591++    let _ = stream.flush();1592++}1593++1594++/// Serve the built `404.html` (same chrome as every page) with status1595++/// 404. Falls back to the tiny in-binary stub when the file is absent.1596++/// POSTs keep the old non-echoing stub (`f0rge #85`).1597++fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {1598++    if head.method != "GET" && head.method != "HEAD" {1599++        static_error(stream, 404);1600+         return;1601+     }1602++    let dest = shared.www.join("404.html");1603++    let Ok(meta) = std::fs::metadata(&dest) else {1604++        static_error(stream, 404);1605++        return;1606++    };1607++    if !meta.is_file() {1608++        static_error(stream, 404);1609++        return;1610++    }1611++    if head.method == "HEAD" {1612++        respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());1613++    } else {1614++        respond_file(1615++            stream,1616++            404,1617++            "text/html; charset=utf-8",1618++            &[],1619++            &dest,1620++            meta.len(),1621++        );1622++    }1623++}1624++1625++/// Stream a file body with `Content-Length` (no full-pack buffering).1626++fn respond_file(1627++    stream: &mut TcpStream,1628++    code: u16,1629++    content_type: &str,1630++    extra: &[(&str, &str)],1631++    path: &Path,1632++    len: u64,1633++) {1634++    respond_file_head(stream, code, content_type, extra, len);1635+     if let Ok(mut f) = std::fs::File::open(path) {1636+         let _ = std::io::copy(&mut f, stream);1637+     }1638+diff --git a/static/style.css b/static/style.css1639+index 95af580..471097d 1006441640+--- a/static/style.css1641++++ b/static/style.css1642+@@ -17,6 +17,7 @@1643+   --yellow:#e0af68;1644+   --red:#f7768e;1645+   --border:#292e42;1646++  --field-border:#6a7590;1647+   --visited:#9aa5ce;1648+   --btn-fg:#16161e;1649+   --state-open:#9ece6a;1650+@@ -66,6 +67,7 @@1651+     --yellow:#7d5f33;1652+     --red:#8c4351;1653+     --border:#d6dae2;1654++    --field-border:#8a93a5;1655+     --visited:#5a4a78;1656+     --btn-fg:#ffffff;1657+     --state-open:#33635c;1658+@@ -77,9 +79,9 @@1659+     --tok-com:#4a5273;1660+     --tok-fn:#34548a;1661+     --tok-type:#0f4b6e;1662+-    --diff-add:#33635c;1663++    --diff-add:#2a534d;1664+     --diff-add-bg:#c6d4ca;1665+-    --diff-del:#8c4351;1666++    --diff-del:#7a3543;1667+     --diff-del-bg:#d9c2c7;1668+     --diff-hunk:#0f4b6e;1669+     --diff-file:#5a4a78;1670+@@ -141,8 +143,8 @@ td.num{width:1%;white-space:nowrap}1671+ /* Forms: full-width on mobile, >=40px touch targets. */1672+ form{max-width:44rem;margin:1rem 0;min-width:0}1673+ label{display:block;margin:.75rem 0 .25rem;font-weight:600}1674+-input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt);1675+-  color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px}1676++input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--surface);1677++  color:var(--fg);border:1px solid var(--field-border);padding:.5rem .6rem;font:inherit;min-height:40px}1678+ input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0}1679+ textarea{min-height:12rem}1680+ button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center;1681+@@ -193,6 +195,7 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align1682+ .site-logo{display:block;width:auto;height:1.75rem;flex:none}1683+ .hu13-word{font-weight:700;color:var(--blue)}1684+ .sep{flex:none;color:var(--muted)}1685++.nav-sep{flex:0 0 auto;color:var(--muted)}1686+ .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}1687+ .nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%}1688+ .site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap}1689+@@ -362,6 +365,7 @@ time{font-variant-numeric:tabular-nums}1690+ .df-bar-add{display:block;height:100%;background:var(--green)}1691+ .difffile .pre-scroll{margin:0}1692+ pre.diff{margin:0;border:0;background:var(--surface)}1693++pre.diff>span{display:block}1694+ .df-line{display:block}1695+ .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}1696+ .dln+.dln{border-right:1px solid var(--border);margin-right:.5em}1697+@@ -370,11 +374,26 @@ pre.diff{margin:0;border:0;background:var(--surface)}1698+ .diff-file{color:var(--diff-file);font-weight:600}1699+ .df-meta{color:var(--fg-dim)}1700+ 1701++/* Tablet nav (f0rge #91): 768–1099px scrolls off the last tab with no1702++   affordance. Give the nav its own wrapped row until the full one-row1703++   header fits at 1100px; separators would otherwise begin a line. */1704++@media (min-width:768px) and (max-width:1099px){1705++  .site-header .wrap.header-bar{flex-wrap:wrap}1706++  .site-header .sep,.site-header .nav-sep{display:none}1707++  .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)}1708++  .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}1709++}1710++/* Branch lanes (f0rge #92): never break inside a branch name; wrap only1711++   between head, arrow and base (a very long token scrolls its cell).1712++   Mobile card cells inherit word-break:break-all, so reset it here. */1713++.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}1714++.lanes .lane{white-space:nowrap}1715++.lanes .lane-arrow{color:var(--muted)}1716+ /* Responsive fixes (f0rge #75): every nav destination visible at 390px,1717+    file table keeps Name + Age, meta chips never overflow. */1718+ @media (max-width:767px){1719+   .site-header .wrap.header-bar{flex-wrap:wrap}1720+-  .site-header .sep{display:none}1721++  .site-header .sep,.site-header .nav-sep{display:none}1722+   .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)}1723+   .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}1724+   /* Dense file table: drop "Last commit" below 768px instead of wrapping1725+diff --git a/tests/clean_out.rs b/tests/clean_out.rs1726+index fdc1c03..1d56d90 1006441727+--- a/tests/clean_out.rs1728++++ b/tests/clean_out.rs1729+@@ -122,3 +122,61 @@ fn refuses_to_clean_repo_or_its_parent() {1730+         assert!(sender.is_file(), "repo untouched");1731+     }1732+ }1733++1734++#[test]1735++fn refuses_relative_dotdot_output_and_keeps_parent() {1736++    let root = fresh_dir("dotdot");1737++    let repo = root.join("repo");1738++    std::fs::create_dir_all(repo.join("src")).unwrap();1739++    git(&repo, &["init", "-b", "main"]);1740++    git(&repo, &["config", "user.name", "hu13-test"]);1741++    git(&repo, &["config", "user.email", "[email protected]"]);1742++    git(&repo, &["config", "commit.gpgsign", "false"]);1743++    std::fs::write(repo.join("README.md"), "# keep\n").unwrap();1744++    std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();1745++    git(&repo, &["add", "-A"]);1746++    git(&repo, &["commit", "-m", "one"]);1747++    let canary = root.join("canary.txt");1748++    std::fs::write(&canary, "must survive\n").unwrap();1749++1750++    let res = Command::new(env!("CARGO_BIN_EXE_hu13"))1751++        .arg("build")1752++        .arg(&repo)1753++        .arg("-o")1754++        .arg("..")1755++        .current_dir(&repo)1756++        .output()1757++        .expect("spawn hu13 build");1758++    assert!(!res.status.success(), "relative .. must be refused");1759++    let err = String::from_utf8_lossy(&res.stderr);1760++    assert!(err.contains("refusing"), "stderr: {err}");1761++    assert!(canary.is_file(), "parent canary must survive");1762++    assert!(repo.join("README.md").is_file(), "repo must survive");1763++}1764++1765++#[cfg(unix)]1766++#[test]1767++fn refuses_symlinked_ancestor_into_repo() {1768++    let root = fresh_dir("symlink");1769++    let repo = root.join("repo");1770++    std::fs::create_dir_all(repo.join("src")).unwrap();1771++    git(&repo, &["init", "-b", "main"]);1772++    git(&repo, &["config", "user.name", "hu13-test"]);1773++    git(&repo, &["config", "user.email", "[email protected]"]);1774++    git(&repo, &["config", "commit.gpgsign", "false"]);1775++    std::fs::write(repo.join("README.md"), "# keep\n").unwrap();1776++    std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();1777++    git(&repo, &["add", "-A"]);1778++    git(&repo, &["commit", "-m", "one"]);1779++    let alias = root.join("alias");1780++    std::os::unix::fs::symlink(&repo, &alias).unwrap();1781++1782++    let res = build(&repo, &alias.join("src"));1783++    assert!(!res.status.success(), "symlinked src must be refused");1784++    let err = String::from_utf8_lossy(&res.stderr);1785++    assert!(err.contains("refusing"), "stderr: {err}");1786++    assert!(1787++        repo.join("src/main.rs").is_file(),1788++        "tracked source must survive"1789++    );1790++}1791+diff --git a/tests/light_theme.rs b/tests/light_theme.rs1792+index 42f309b..575c1a9 1006441793+--- a/tests/light_theme.rs1794++++ b/tests/light_theme.rs1795+@@ -167,7 +167,7 @@ fn no_hardcoded_palette_hex_outside_root() {1796+         "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e",1797+         "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328",1798+         "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7",1799+-        "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e",1800++        "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", "#2a534d", "#7a3543", "#6a7590", "#8a93a5",1801+     ];1802+     for hex in palette {1803+         assert!(1804+@@ -200,8 +200,8 @@ fn diff_vars_defined_dark_and_light() {1805+         "--diff-add:#9ece6a",1806+         "--diff-del:#f7768e",1807+         "--diff-hunk:#7dcfff",1808+-        "--diff-add:#33635c",1809+-        "--diff-del:#8c4351",1810++        "--diff-add:#2a534d",1811++        "--diff-del:#7a3543",1812+         "--diff-hunk:#0f4b6e",1813+     ] {1814+         assert!(css.contains(var), "diff var defined: {var}");1815+diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs1816+new file mode 1006441817+index 0000000..f9625c81818+--- /dev/null1819++++ b/tests/pages_inbox.test.mjs1820+@@ -0,0 +1,159 @@1821++// Cloudflare Pages inbox parity tests (f0rge #79). Run:1822++//   node --test tests/pages_inbox.test.mjs1823++// No network, no wrangler: a fake R2 bucket exercises validation,1824++// origin gate, ack, storage shape, rate and quota.1825++import test from "node:test";1826++import assert from "node:assert/strict";1827++import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js";1828++1829++class FakeBucket {1830++  constructor() {1831++    this.objects = new Map();1832++  }1833++  async list({ limit = 1000 } = {}) {1834++    return {1835++      objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),1836++      truncated: this.objects.size > limit,1837++    };1838++  }1839++  async put(key, value) {1840++    this.objects.set(key, String(value));1841++  }1842++}1843++1844++let seq = 0;1845++function env(extra = {}) {1846++  return {1847++    INBOX: new FakeBucket(),1848++    INBOX_SALT: "test-salt",1849++    ...extra,1850++  };1851++}1852++1853++function post(kind, body, headers = {}, e = env()) {1854++  const request = new Request(`https://hu13.test/inbox/${kind}`, {1855++    method: "POST",1856++    headers: {1857++      "content-type": "application/x-www-form-urlencoded",1858++      "cf-connecting-ip": `203.0.113.${++seq}`,1859++      ...headers,1860++    },1861++    body,1862++  });1863++  return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({1864++    res,1865++    text: await res.text(),1866++    env: e,1867++  }));1868++}1869++1870++const validIssue = "title=Hello&body=World&license=agree";1871++const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on";1872++1873++test("GET is 405 with POST allowed and no-store/nosniff", async () => {1874++  const res = await onRequestGet();1875++  assert.equal(res.status, 405);1876++  assert.equal(res.headers.get("allow"), "POST");1877++  assert.equal(res.headers.get("x-content-type-options"), "nosniff");1878++  assert.equal(res.headers.get("cache-control"), "no-store");1879++});1880++1881++test("valid issue stores one JSON object and returns an ack", async () => {1882++  const e = env();1883++  const { res, text } = await post("issue", validIssue, {}, e);1884++  assert.equal(res.status, 200);1885++  assert.match(text, /ack: <code>[0-9a-f-]{36}<\/code>/);1886++  assert.equal(e.INBOX.objects.size, 1);1887++  const [key, value] = [...e.INBOX.objects.entries()][0];1888++  assert.match(key, /^[0-9a-f-]{36}\.json$/);1889++  const doc = JSON.parse(value);1890++  assert.equal(doc.type, "issue");1891++  assert.equal(doc.title, "Hello");1892++  assert.equal(doc.body, "World");1893++  assert.equal(doc.license_version, "v1");1894++  assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);1895++  assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/);1896++  assert.ok(!text.includes("World"), "ack never reflects input");1897++});1898++1899++test("valid pr-prompt uses prompt (and accepts body alias)", async () => {1900++  for (const body of [validPrompt, "title=P&body=alias&license=yes"]) {1901++    const e = env();1902++    const { res } = await post("pr-prompt", body, {}, e);1903++    assert.equal(res.status, 200);1904++    const doc = JSON.parse([...e.INBOX.objects.values()][0]);1905++    assert.equal(doc.type, "pr-prompt");1906++    assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias");1907++  }1908++});1909++1910++test("missing license, empty fields and oversize are 400", async () => {1911++  for (const body of [1912++    "title=Hello&body=World",1913++    "title=&body=World&license=agree",1914++    "title=Hello&body=&license=agree",1915++    `title=${"x".repeat(301)}&body=W&license=agree`,1916++  ]) {1917++    const { res } = await post("issue", body);1918++    assert.equal(res.status, 400, body);1919++  }1920++});1921++1922++test("honeypot fakes success without storing", async () => {1923++  const e = env();1924++  const { res, text } = await post(1925++    "issue",1926++    "title=Hello&body=World&license=agree&website=bot",1927++    {},1928++    e,1929++  );1930++  assert.equal(res.status, 200);1931++  assert.match(text, /ack: <code>/);1932++  assert.equal(e.INBOX.objects.size, 0);1933++});1934++1935++test("malformed escapes, non-UTF-8 and NUL are 400", async () => {1936++  for (const body of [1937++    "title=%ZZ&body=W&license=agree",1938++    "title=%FF&body=W&license=agree",1939++    "title=He%00llo&body=W&license=agree",1940++  ]) {1941++    const { res } = await post("issue", body);1942++    assert.equal(res.status, 400, body);1943++  }1944++});1945++1946++test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => {1947++  const bad = await post("issue", validIssue, { origin: "https://evil.test" });1948++  assert.equal(bad.res.status, 403);1949++  const nullOrigin = await post("issue", validIssue, { origin: "null" });1950++  assert.equal(nullOrigin.res.status, 403);1951++  const same = await post("issue", validIssue, { origin: "https://hu13.test" });1952++  assert.equal(same.res.status, 200);1953++});1954++1955++test("wrong content type and oversized bodies are rejected", async () => {1956++  const json = await post("issue", validIssue, {1957++    "content-type": "application/json",1958++  });1959++  assert.equal(json.res.status, 400);1960++  const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`;1961++  const over = await post("issue", big);1962++  assert.equal(over.res.status, 413);1963++});1964++1965++test("quota is enforced before storing", async () => {1966++  const e = env({ INBOX_QUOTA: "1" });1967++  const first = await post("issue", validIssue, {}, e);1968++  assert.equal(first.res.status, 200);1969++  const second = await post("issue", validIssue, {}, e);1970++  assert.equal(second.res.status, 503);1971++  assert.equal(e.INBOX.objects.size, 1);1972++});1973++[hu13: diff truncated at 2000 lines]