.forge/prs/146353cc-69c7-4238-bfd3-d2adebd844f7/patch.diff 89.2 KiB 2322 lines Raw
1 From 983e303b40b7a6e09e211f7b46d4279e6f22bbc2 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 +1974 +test("missing R2 binding or salt fails closed", async () => {1975 +  const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });1976 +  assert.equal(noBucket.res.status, 503);1977 +  const noSalt = await post("issue", validIssue, {}, { INBOX: new FakeBucket() });1978 +  assert.equal(noSalt.res.status, 503);1979 +});1980 diff --git a/tests/raw_safety.rs b/tests/raw_safety.rs1981 index 349a1b5..378cf50 1006441982 --- a/tests/raw_safety.rs1983 +++ b/tests/raw_safety.rs1984 @@ -147,7 +147,11 @@ fn raw_active_types_download_never_render() {1985              Some("text/plain; charset=utf-8"),1986              "{path}"1987          );1988 -        assert_eq!(header(&resp, "Content-Disposition"), Some("inline"));1989 +        assert_eq!(1990 +            header(&resp, "Content-Disposition"),1991 +            Some("attachment"),1992 +            "{path} downloads, matching _headers"1993 +        );1994      }1995  1996      let resp = get(&srv.addr, "/raw/blob.bin");1997 diff --git a/tests/review3_render.rs b/tests/review3_render.rs1998 new file mode 1006441999 index 0000000..65b7d2e2000 --- /dev/null2001 +++ b/tests/review3_render.rs2002 @@ -0,0 +1,264 @@2003 +//! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer,2004 +//! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social2005 +//! and feed URLs, dir headings.2006 +2007 +use std::io::{BufRead, BufReader, Read, Write};2008 +use std::net::TcpStream;2009 +use std::path::{Path, PathBuf};2010 +use std::process::{Child, Command, Stdio};2011 +use std::sync::atomic::{AtomicU64, Ordering};2012 +use std::time::Duration;2013 +2014 +static SEQ: AtomicU64 = AtomicU64::new(0);2015 +2016 +fn fresh(prefix: &str) -> PathBuf {2017 +    let dir = std::env::temp_dir().join(format!(2018 +        "hu13-review3-{prefix}-{}-{}",2019 +        std::process::id(),2020 +        std::time::SystemTime::now()2021 +            .duration_since(std::time::UNIX_EPOCH)2022 +            .map(|d| d.as_nanos())2023 +            .unwrap_or(0)2024 +            .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))2025 +    ));2026 +    std::fs::create_dir_all(&dir).unwrap();2027 +    dir2028 +}2029 +2030 +fn git(repo: &Path, args: &[&str]) {2031 +    let st = Command::new("git")2032 +        .arg("-C")2033 +        .arg(repo)2034 +        .args(args)2035 +        .status()2036 +        .expect("spawn git");2037 +    assert!(st.success(), "git {args:?} failed");2038 +}2039 +2040 +fn write(repo: &Path, rel: &str, body: &str) {2041 +    let dest = repo.join(rel);2042 +    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();2043 +    std::fs::write(dest, body).unwrap();2044 +}2045 +2046 +const ISSUE: &str = "11111111-1111-4111-8111-111111111111";2047 +const PR: &str = "44444444-4444-4444-8444-444444444444";2048 +2049 +fn fixture() -> PathBuf {2050 +    let repo = fresh("repo");2051 +    git(&repo, &["init", "-b", "main"]);2052 +    git(&repo, &["config", "user.name", "hu13-test"]);2053 +    git(&repo, &["config", "user.email", "[email protected]"]);2054 +    git(&repo, &["config", "commit.gpgsign", "false"]);2055 +    write(&repo, "README.md", "# review fixture\n");2056 +    write(&repo, "a#b.txt", "hash name\n");2057 +    write(&repo, "q?x.txt", "query name\n");2058 +    write(&repo, "src/hi.txt", "subdir\n");2059 +    write(&repo, "crlf.txt", "a\r\nb\r\n");2060 +    write(2061 +        &repo,2062 +        &format!(".forge/issues/{ISSUE}/issue.md"),2063 +        &format!(2064 +            "---\nid: {ISSUE}\ntitle: Closed issue\nstate: closed\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"2065 +        ),2066 +    );2067 +    write(2068 +        &repo,2069 +        &format!(".forge/prs/{PR}/pr.md"),2070 +        &format!(2071 +            "---\nid: {PR}\ntitle: Closed pull\nstate: closed\nbase: main\nhead: feature/topic\ncreated: 2026-09-10T03:30:00Z\ndraft: false\nissues: []\nci: pass\n---\nbody\n"2072 +        ),2073 +    );2074 +    write(2075 +        &repo,2076 +        ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",2077 +        "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 3\n---\nsummary body\n",2078 +    );2079 +    write(2080 +        &repo,2081 +        ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",2082 +        &format!("Checking hu13 at {}\nfinished\n", repo.display()),2083 +    );2084 +    git(&repo, &["add", "-A"]);2085 +    git(&repo, &["commit", "-m", "one"]);2086 +    repo2087 +}2088 +2089 +fn build(repo: &Path, out: &Path) -> std::process::Output {2090 +    Command::new(env!("CARGO_BIN_EXE_hu13"))2091 +        .arg("build")2092 +        .arg(repo)2093 +        .arg("-o")2094 +        .arg(out)2095 +        .arg("--public-origin")2096 +        .arg("https://hu13.example")2097 +        .output()2098 +        .expect("spawn hu13 build")2099 +}2100 +2101 +fn read(path: &Path) -> String {2102 +    std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))2103 +}2104 +2105 +#[test]2106 +fn review3_build_regressions() {2107 +    let repo = fixture();2108 +    let out = fresh("out");2109 +    let res = build(&repo, &out);2110 +    assert!(2111 +        res.status.success(),2112 +        "build: {}",2113 +        String::from_utf8_lossy(&res.stderr)2114 +    );2115 +2116 +    let index = read(&out.join("index.html"));2117 +    assert!(2118 +        index.contains("href=\"/files/a%23b.txt.html\""),2119 +        "hash filename is percent-encoded in links"2120 +    );2121 +    assert!(2122 +        index.contains("href=\"/files/q%3Fx.txt.html\""),2123 +        "query filename is percent-encoded in links"2124 +    );2125 +    assert!(!index.contains("href=\"/files/a#b.txt.html\""));2126 +    assert!(2127 +        index.contains("content=\"https://hu13.example/og.png\""),2128 +        "og:image is absolute with --public-origin"2129 +    );2130 +    assert!(2131 +        index.contains("href=\"/new-issue.html\">submit</a>"),2132 +        "footer submit points at the form, not POST-only /inbox/issue"2133 +    );2134 +2135 +    let hash_page = read(&out.join("files/a#b.txt.html"));2136 +    assert!(hash_page.contains("href=\"/raw/a%23b.txt\""));2137 +    let crlf = read(&out.join("files/crlf.txt.html"));2138 +    assert!(crlf.contains("id=\"L2\""), "two lines survive");2139 +    assert!(2140 +        !crlf.contains("id=\"L3\""),2141 +        "no phantom L404 after trailing CRLF"2142 +    );2143 +    assert!(2144 +        !crlf.contains("&#13;") && !crlf.contains('\r'),2145 +        "CR stripped"2146 +    );2147 +2148 +    let dir_page = read(&out.join("files/src.html"));2149 +    assert!(dir_page.contains("<h1>src/</h1>"));2150 +    assert!(!dir_page.contains("dir: src"), "debug heading removed");2151 +2152 +    let issues = read(&out.join("issues/index.html"));2153 +    let pulls = read(&out.join("pulls/index.html"));2154 +    for page in [&issues, &pulls] {2155 +        assert!(!page.contains("title=\"<"), "no HTML in title attributes");2156 +        assert!(2157 +            !page.contains("</span>\">"),2158 +            "no leaked span attribute text"2159 +        );2160 +        assert!(2161 +            !page.contains("</time>\">"),2162 +            "no leaked time attribute text"2163 +        );2164 +    }2165 +    assert!(issues.contains("title=\"closed\""), "plain state title");2166 +    assert!(pulls.contains("class=\"lane\""), "branch lanes are tokens");2167 +2168 +    let ci = read(&out.join("ci/index.html"));2169 +    assert!(2170 +        !ci.contains(&repo.display().to_string()),2171 +        "builder path redacted from CI logs"2172 +    );2173 +    assert!(2174 +        ci.contains("&lt;repo&gt;") || ci.contains("<repo>"),2175 +        "redaction marker"2176 +    );2177 +2178 +    let feed = read(&out.join("feed.atom"));2179 +    assert!(2180 +        feed.contains("<link rel=\"self\" href=\"https://hu13.example/feed.atom\"/>"),2181 +        "feed self link absolute"2182 +    );2183 +    assert!(2184 +        feed.contains("href=\"https://hu13.example/commit/"),2185 +        "entry links absolute"2186 +    );2187 +2188 +    let not_found = read(&out.join("404.html"));2189 +    assert!(not_found.contains("<h1>404</h1>"));2190 +    assert!(not_found.contains("site-footer"), "branded 404 has footer");2191 +    assert!(out.join("_routes.json").is_file(), "Pages routes emitted");2192 +    let routes = read(&out.join("_routes.json"));2193 +    assert!(routes.contains("\"/inbox/*\""));2194 +}2195 +2196 +struct Server {2197 +    child: Child,2198 +    addr: String,2199 +}2200 +2201 +impl Drop for Server {2202 +    fn drop(&mut self) {2203 +        self.child.kill().ok();2204 +        self.child.wait().ok();2205 +    }2206 +}2207 +2208 +fn start(www: &Path, inbox: &Path) -> Server {2209 +    let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))2210 +        .arg("serve")2211 +        .arg("--www")2212 +        .arg(www)2213 +        .arg("--inbox-dir")2214 +        .arg(inbox)2215 +        .arg("--listen")2216 +        .arg("127.0.0.1:0")2217 +        .stdout(Stdio::piped())2218 +        .stderr(Stdio::null())2219 +        .spawn()2220 +        .expect("spawn serve");2221 +    let stdout = child.stdout.take().expect("stdout");2222 +    let mut addr = String::new();2223 +    for line in BufReader::new(stdout).lines().take(50).flatten() {2224 +        if let Some(rest) = line.split("listening on ").nth(1) {2225 +            addr = rest.trim().to_string();2226 +            break;2227 +        }2228 +    }2229 +    assert!(!addr.is_empty(), "serve printed no address");2230 +    Server { child, addr }2231 +}2232 +2233 +fn get(addr: &str, path: &str) -> String {2234 +    let mut s = TcpStream::connect(addr).expect("connect");2235 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();2236 +    write!(2237 +        s,2238 +        "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"2239 +    )2240 +    .unwrap();2241 +    let mut out = Vec::new();2242 +    s.read_to_end(&mut out).unwrap();2243 +    String::from_utf8_lossy(&out).into_owned()2244 +}2245 +2246 +#[test]2247 +fn serve_uses_branded_404_and_streams_encoded_paths() {2248 +    let repo = fixture();2249 +    let out = fresh("serve-out");2250 +    let res = build(&repo, &out);2251 +    assert!(res.status.success());2252 +    let inbox = fresh("inbox");2253 +    let srv = start(&out, &inbox);2254 +    std::thread::sleep(Duration::from_millis(100));2255 +2256 +    let missing = get(&srv.addr, "/definitely-not-here");2257 +    assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");2258 +    assert!(missing.contains("<h1>404</h1>"));2259 +    assert!(missing.contains("class=\"site-footer\""), "same chrome");2260 +2261 +    let encoded = get(&srv.addr, "/files/a%23b.txt.html");2262 +    assert!(encoded.starts_with("HTTP/1.1 200"), "{encoded}");2263 +    let raw = get(&srv.addr, "/raw/a%23b.txt");2264 +    assert!(raw.starts_with("HTTP/1.1 200"), "{raw}");2265 +    assert!(raw.contains("Content-Disposition: attachment"));2266 +}2267 diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rs2268 index 0c0fea3..d3ae23d 1006442269 --- a/tests/theme_contrast.rs2270 +++ b/tests/theme_contrast.rs2271 @@ -119,6 +119,8 @@ fn both_themes_pass_aa_for_text_colors() {2272          ("--tok-type", "--surface"),2273          ("--diff-file", "--surface"),2274          ("--diff-hunk", "--surface"),2275 +        ("--diff-add", "--diff-add-bg"),2276 +        ("--diff-del", "--diff-del-bg"),2277      ];2278      check("dark", dark, &pairs);2279      check("light", light, &pairs);2280 @@ -157,3 +159,24 @@ fn themes_define_the_same_vars() {2281          );2282      }2283  }2284 +2285 +#[test]2286 +fn field_boundaries_pass_ui_contrast() {2287 +    // f0rge #90: a control boundary needs 3:1 against its own fill.2288 +    let css = css();2289 +    let dark = block(&css, css.find(":root{").expect("dark root"));2290 +    let light = block(2291 +        &css,2292 +        css.find("@media (prefers-color-scheme:light)")2293 +            .expect("light block"),2294 +    );2295 +    for (theme, block) in [("dark", dark), ("light", light)] {2296 +        let border = var(block, "--field-border");2297 +        let fill = var(block, "--surface");2298 +        let ratio = contrast(&border, &fill);2299 +        assert!(2300 +            ratio >= 3.0,2301 +            "{theme}: --field-border {border} on --surface {fill} = {ratio:.2}:1 (UI needs 3)"2302 +        );2303 +    }2304 +}2305 diff --git a/wrangler.toml b/wrangler.toml2306 new file mode 1006442307 index 0000000..4ed018f2308 --- /dev/null2309 +++ b/wrangler.toml2310 @@ -0,0 +1,9 @@2311 +# Cloudflare Pages project config (f0rge #79).2312 +# `npx wrangler pages deploy dist` reads this file and the functions/ dir.2313 +name = "hu13"2314 +pages_build_output_dir = "dist"2315 +compatibility_date = "2026-01-01"2316 +2317 +[[r2_buckets]]2318 +binding = "INBOX"2319 +bucket_name = "hu13-inbox"2320 -- 2321 2.56.02322