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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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("<script>"));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(" ") && !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("<repo>") || 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