From 983e303b40b7a6e09e211f7b46d4279e6f22bbc2 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 17:41:42 +0000 Subject: [PATCH] review #3: harden build/serve, fix render regressions, add Pages inbox (#77-#96) --- .forge/ci/steps/04_pages_inbox.sh | 8 + .forge/wiki/deploy | 5 + docs/cloudflare.md | 95 ++++++++ functions/inbox/[kind].js | 349 ++++++++++++++++++++++++++++++ src/build.rs | 304 ++++++++++++++++++++++++-- src/feed.rs | 53 ++++- src/forge.rs | 55 ++++- src/highlight.rs | 81 +++++-- src/layout.rs | 87 ++++++-- src/serve.rs | 87 +++++--- static/style.css | 29 ++- tests/clean_out.rs | 58 +++++ tests/light_theme.rs | 6 +- tests/pages_inbox.test.mjs | 159 ++++++++++++++ tests/raw_safety.rs | 6 +- tests/review3_render.rs | 264 ++++++++++++++++++++++ tests/theme_contrast.rs | 23 ++ wrangler.toml | 9 + 18 files changed, 1566 insertions(+), 112 deletions(-) create mode 100755 .forge/ci/steps/04_pages_inbox.sh create mode 100644 docs/cloudflare.md create mode 100644 functions/inbox/[kind].js create mode 100644 tests/pages_inbox.test.mjs create mode 100644 tests/review3_render.rs create mode 100644 wrangler.toml diff --git a/.forge/ci/steps/04_pages_inbox.sh b/.forge/ci/steps/04_pages_inbox.sh new file mode 100755 index 0000000..bc39c5a --- /dev/null +++ b/.forge/ci/steps/04_pages_inbox.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Cloudflare Pages inbox parity (f0rge #79). Skips only when Node is absent. +set -eu +if ! command -v node >/dev/null 2>&1; then + echo "pages inbox: node not installed; skipping" + exit 0 +fi +node --test tests/pages_inbox.test.mjs diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy index 3e94c60..1fd24c2 100644 --- a/.forge/wiki/deploy +++ b/.forge/wiki/deploy @@ -69,6 +69,11 @@ What happens inside (`entrypoint.sh`): `dist/_headers` carries the `/raw/**` download policy for Pages. On nginx, add the equivalent `add_header Content-Disposition attachment` for `/raw/` — committed HTML/SVG must never render from that path. +- Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The + Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and + secret `INBOX_SALT` implement the same contract. See + `docs/cloudflare.md` for the bucket, binding, secret and WAF rate-rule + recipe. `dist/_routes.json` limits Functions to `/inbox/*`. - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped. If it ever tops 2MB stripped, the cut list is: replace `yaml_serde` diff --git a/docs/cloudflare.md b/docs/cloudflare.md new file mode 100644 index 0000000..dedbd9d --- /dev/null +++ b/docs/cloudflare.md @@ -0,0 +1,95 @@ +# Deploy hu13 on Cloudflare Pages (Free) + +The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On +Pages the inbox is implemented by a Pages Function backed by R2; a +static-only upload has no working inbox. + +## 1. Build the site + +```sh +cargo build --release +./target/release/hu13 build . -o dist \ + --public-origin https://hu13.example.com +``` + +`--public-origin` makes the clone command, `og:image`, Atom links and +canonical URLs absolute. The build refuses output over Cloudflare's +limits (20,000 files, 25 MiB/file) and writes `_headers` and +`_routes.json` into `dist/`. + +## 2. Create the R2 bucket and Pages project + +```sh +npx wrangler r2 bucket create hu13-inbox +npx wrangler pages project create hu13 +``` + +Bind the bucket to the Pages project as **`INBOX`** (dashboard: +Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in +`wrangler.toml`). Set the required secret: + +```sh +npx wrangler pages secret put INBOX_SALT --project-name hu13 +``` + +`INBOX_SALT` is a random string (for example `openssl rand -hex 32`). +The function hashes `v1|||` with SHA-256; no +plaintext IP is stored. Rotating the secret unlinks old hashes. + +Optional variables: `INBOX_RATE_PER_HOUR` (default 2), +`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300), +`INBOX_MAX_BODY` (20000). + +## 3. Deploy + +```sh +npx wrangler pages deploy dist --project-name hu13 +``` + +Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json` +limits Functions to `/inbox/*`, so every other request is static. +Keep `wrangler.toml` at the repo root for the R2 binding. + +## What the Function implements + +`functions/inbox/[kind].js` serves both mailboxes: + +- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`, + `body` accepted as an alias) +- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 400 +- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤100 +- license checkbox required; filled honeypot returns a fake receipt + without storing +- same-origin `Origin`/`Referer` gate; no input is echoed or logged +- fixed ~500 ms response delay; `Cache-Control: no-store` and + `X-Content-Type-Options: nosniff` +- one R2 object per accepted submission: + `.json` with `type/title/body/handle/contact/license_version/ + received_at/salted_ip_hash` (same shape as the Docker inbox) + +Rate limits are layered: the Function has a best-effort isolate-local +bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting +rule for `/inbox/*` (for example 10 requests/minute per IP) as the +durable control. The global quota is checked against the R2 bucket +before storing; R2 list consistency means a burst can briefly exceed it. + +## Reading submissions + +```sh +npx wrangler r2 object list hu13-inbox +npx wrangler r2 object get hu13-inbox/.json +``` + +`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`; +it does not read R2. Copy objects down if you want the terminal reader. + +## Limits and caveats + +- Pages Free: 20,000 files and 25 MiB per file per deployment. The + build enforces both; large assets belong outside git or in Git LFS. +- The function bundle must be deployed with `functions/`; uploading only + `dist/` (drag-and-drop or a plain static host) gives a dead inbox. +- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no + smart server. No GitHub dependency. +- Committed `/raw/*` bytes always download (`Content-Disposition: + attachment`) on both `serve` and Pages. diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js new file mode 100644 index 0000000..d7592b8 --- /dev/null +++ b/functions/inbox/[kind].js @@ -0,0 +1,349 @@ +// Cloudflare Pages Functions inbox (f0rge #79). +// +// One dynamic route serves both mailboxes: /inbox/issue and +// /inbox/pr-prompt. Contract parity with `hu13 serve`: +// urlencoded only, 32 KiB cap, strict UTF-8, NUL rejected, +// controls stripped, title/body caps, license required, +// same-origin gate, fixed delay, ack uuid, no echo. +// Storage is one JSON object per submission in the R2 binding INBOX. +// +// Bindings (Pages project settings / wrangler.toml): +// INBOX R2 bucket +// INBOX_SALT secret (required; SHA-256 salt for IP hashes) +// Optional: INBOX_RATE_PER_HOUR (default 2), INBOX_QUOTA (default 500). + +const MAX_BYTES = 32 * 1024; +const MAX_TITLE = 300; +const MAX_BODY = 20000; +const MAX_HANDLE = 40; +const MAX_CONTACT = 100; +const LICENSE_VERSION = "v1"; +const ACK_DELAY_MS = 500; + +const encoder = new TextEncoder(); + +function esc(s) { + return String(s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); +} + +function page(title, body) { + return ` + + + +${esc(title)} +

${esc(title)}

+${body} +

hu13

+`; +} + +function answer(status, body, extra = {}, delay = true) { + const headers = { + "content-type": "text/html; charset=utf-8", + "x-content-type-options": "nosniff", + "cache-control": "no-store", + ...extra, + }; + const response = () => new Response(body, { status, headers }); + if (!delay) return response(); + return new Promise((resolve) => + setTimeout(() => resolve(response()), ACK_DELAY_MS), + ); +} + +function errorPage(status, detail, extra = {}) { + const title = status === 405 ? "method not allowed" : status === 429 + ? "too many requests" + : status === 503 + ? "unavailable" + : status === 413 + ? "too large" + : "bad request"; + return answer( + status, + page(title, `

${esc(detail)}

`), + extra, + ); +} + +function ackPage(uuid) { + return page( + "received", + `

ack: ${esc(uuid)}

+

Thank you. This identifier was shown once and is not a lookup URL.

`, + ); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Same-origin gate: browsers send Origin/Referer; agents (curl) send +// neither and pass. `null`/unparsable/mismatched hosts fail closed. +function originAllowed(request) { + let host; + try { + host = new URL(request.url).host.toLowerCase(); + } catch { + return false; + } + for (const name of ["origin", "referer"]) { + const value = request.headers.get(name); + if (value === null) continue; + if (!value || value.trim().toLowerCase() === "null") return false; + try { + if (new URL(value).host.toLowerCase() !== host) return false; + } catch { + return false; + } + } + return true; +} + +function hexByte(b) { + if (b >= 0x30 && b <= 0x39) return b - 0x30; + if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10; + if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10; + return -1; +} + +// Strict percent-decoder matching hu13's `parse_form`: malformed `%`, +// non-UTF-8, or a bad escape returns null (400) instead of guessing. +function decodeComponent(input) { + const raw = encoder.encode(input); + const out = []; + let i = 0; + while (i < raw.length) { + let b = raw[i++]; + if (b === 0x25) { + if (i + 1 >= raw.length) return null; + const hi = hexByte(raw[i++]); + const lo = hexByte(raw[i++]); + if (hi < 0 || lo < 0) return null; + b = (hi << 4) | lo; + } else if (b === 0x2b) { + b = 0x20; + } + out.push(b); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(out), + ); + } catch { + return null; + } +} + +function parseForm(body) { + if (body === "") return []; + const pairs = []; + for (const part of body.split("&")) { + const eq = part.indexOf("="); + const key = eq < 0 ? part : part.slice(0, eq); + const value = eq < 0 ? "" : part.slice(eq + 1); + const k = decodeComponent(key); + const v = decodeComponent(value); + if (k === null || v === null) return null; + pairs.push([k, v]); + } + return pairs; +} + +function field(pairs, name) { + const hit = pairs.find(([k]) => k === name); + return hit ? hit[1] : undefined; +} + +function stripControls(s) { + return [...s] + .filter((c) => c === "\n" || c === "\t" || !/\p{Cc}/u.test(c)) + .join(""); +} + +function licenseChecked(value) { + if (value === undefined) return false; + return ["on", "agree", "yes", "true", "1", "checked", "accept"].includes( + value.trim().toLowerCase(), + ); +} + +function validate(kind, pairs, maxTitle = MAX_TITLE, maxBody = MAX_BODY) { + if ( + (field(pairs, "website") || "") !== "" || + (field(pairs, "honeypot") || "") !== "" + ) { + return { kind: "honeypot" }; + } + for (const [k, v] of pairs) { + if (k.includes("\0") || v.includes("\0")) return { kind: "reject" }; + } + const rawTitle = field(pairs, "title") || ""; + const bodyName = kind === "pr-prompt" ? "prompt" : "body"; + let rawBody = field(pairs, bodyName); + if (rawBody === undefined && kind === "pr-prompt") { + rawBody = field(pairs, "body"); + } + rawBody = rawBody || ""; + const title = stripControls(rawTitle); + const body = stripControls(rawBody); + const handle = stripControls(field(pairs, "handle") || ""); + const contact = stripControls(field(pairs, "contact") || ""); + if (title.trim() === "" || body.trim() === "") return { kind: "reject" }; + if ( + [...title].length > maxTitle || + [...body].length > maxBody || + [...handle].length > MAX_HANDLE || + [...contact].length > MAX_CONTACT + ) { + return { kind: "reject" }; + } + if (!licenseChecked(field(pairs, "license"))) return { kind: "reject" }; + return { kind: "accept", title, body, handle, contact }; +} + +async function readCapped(request) { + const declared = request.headers.get("content-length"); + if (declared !== null) { + if (!/^\d+$/.test(declared.trim())) return { error: 400 }; + if (Number(declared) > MAX_BYTES) return { error: 413 }; + } + if (!request.body) return { text: "" }; + const reader = request.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BYTES) { + await reader.cancel(); + return { error: 413 }; + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let at = 0; + for (const chunk of chunks) { + merged.set(chunk, at); + at += chunk.byteLength; + } + try { + return { text: new TextDecoder("utf-8", { fatal: true }).decode(merged) }; + } catch { + return { error: 400 }; + } +} + +async function saltedIpHash(ip, salt) { + const date = new Date().toISOString().slice(0, 10); + const bytes = encoder.encode(`v1|${ip}|${date}|${salt}`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +// Best-effort isolate-local limiter. Cloudflare's edge rate-limiting +// rule for /inbox/* is the durable control; this still stops a single +// hot isolate from storing unlimited submissions. +const buckets = new Map(); +function rateAllowed(key, limit) { + const now = Date.now(); + if (buckets.size > 10000) { + for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k); + } + let b = buckets.get(key); + if (!b || b.reset <= now) { + b = { count: 0, reset: now + 3600_000 }; + buckets.set(key, b); + } + if (b.count >= limit) return false; + b.count += 1; + return true; +} + +async function overQuota(bucket, limit) { + const listed = await bucket.list({ limit: Math.min(limit, 1000) }); + return listed.objects.length >= limit; +} + +function nowRfc3339() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +async function handlePost(context) { + const request = context.request; + const rawKind = context.params ? context.params.kind : ""; + const kind = Array.isArray(rawKind) ? rawKind[0] : rawKind; + if (kind !== "issue" && kind !== "pr-prompt") { + return errorPage(404, "Nothing here.", {}, false); + } + if (!originAllowed(request)) { + return errorPage(403, "Bad origin."); + } + const ctype = (request.headers.get("content-type") || "").toLowerCase(); + if (!ctype.startsWith("application/x-www-form-urlencoded")) { + return errorPage(400, "This endpoint accepts urlencoded forms only."); + } + const read = await readCapped(request); + if (read.error) { + return errorPage(read.error, read.error === 413 ? "Submission too large." : "Bad input."); + } + if (read.text.includes("\0")) return errorPage(400, "Bad input."); + const pairs = parseForm(read.text); + if (pairs === null) return errorPage(400, "Bad input."); + const checked = validate(kind, pairs, + context.env.INBOX_MAX_TITLE ? Number(context.env.INBOX_MAX_TITLE) : MAX_TITLE, + context.env.INBOX_MAX_BODY ? Number(context.env.INBOX_MAX_BODY) : MAX_BODY); + if (checked.kind === "honeypot") { + return answer(200, ackPage(crypto.randomUUID())); + } + if (checked.kind === "reject") return errorPage(400, "Bad input."); + + const env = context.env || {}; + if (!env.INBOX) return errorPage(503, "Could not store that. Try later."); + if (!env.INBOX_SALT) return errorPage(503, "Could not store that. Try later."); + const ip = request.headers.get("cf-connecting-ip") || "unknown"; + const ipHash = await saltedIpHash(ip, env.INBOX_SALT); + const rate = env.INBOX_RATE_PER_HOUR ? Number(env.INBOX_RATE_PER_HOUR) : 2; + if (!rateAllowed(ipHash, rate)) return errorPage(429, "Slow down."); + const quota = env.INBOX_QUOTA ? Number(env.INBOX_QUOTA) : 500; + try { + if (await overQuota(env.INBOX, quota)) { + return errorPage(503, "Inbox full. Try later."); + } + const uuid = crypto.randomUUID(); + const doc = { + type: kind, + title: checked.title, + body: checked.body, + handle: checked.handle, + contact: checked.contact, + license_version: LICENSE_VERSION, + received_at: nowRfc3339(), + salted_ip_hash: ipHash, + }; + await env.INBOX.put(`${uuid}.json`, JSON.stringify(doc) + "\n"); + return answer(200, ackPage(uuid)); + } catch { + return errorPage(503, "Could not store that. Try later."); + } +} + +export async function onRequestGet() { + return errorPage(405, "This endpoint accepts POST only.", { allow: "POST" }, false); +} + +export async function onRequestPost(context) { + try { + return await handlePost(context); + } catch { + // Never echo or log submission text. + return errorPage(503, "Could not store that. Try later."); + } +} diff --git a/src/build.rs b/src/build.rs index d6e3420..532802d 100644 --- a/src/build.rs +++ b/src/build.rs @@ -353,6 +353,12 @@ pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024; /// tried). Prevents a huge README from bloating every page's preview. pub const README_MAX_BYTES: u64 = 1024 * 1024; +/// Cloudflare Pages Free deployment limits (f0rge #81). `raw/` mirrors +/// every blob, so an ordinary asset-heavy repo can exceed these even +/// when the build itself succeeds. +pub const PAGES_MAX_FILE_BYTES: u64 = 25 * 1024 * 1024; +pub const PAGES_MAX_FILES: usize = 20_000; + /// Bytes sniffed for the language-math binary check (first 8KiB). pub const BINARY_SNIFF_BYTES: usize = 8 * 1024; @@ -404,8 +410,25 @@ pub fn is_markdown(path: &str) -> bool { lower.ends_with(".md") || lower.ends_with(".markdown") } +/// Percent-encode a repo path for a URL, then HTML-escape it. Git +/// allows `#`, `?`, `%`, spaces and quotes in names; without this the +/// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal +/// so file paths remain hierarchical; everything outside the unreserved +/// set becomes `%XX` of its UTF-8 bytes. +fn encode_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for &b in path.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') { + out.push(char::from(b)); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + fn href_esc(path: &str) -> String { - esc(path) + esc(&encode_path(path)) } /// Humanized byte size (`24 B`, `2.0 KiB`, `3.0 MiB`) for meta bars @@ -534,7 +557,20 @@ pub fn parse_ts_secs(s: &str) -> Option { let y: i64 = d.next()?.parse().ok()?; let mo: i64 = d.next()?.parse().ok()?; let da: i64 = d.next()?.parse().ok()?; - if !(1..=12).contains(&mo) || !(1..=31).contains(&da) { + // Keep the civil-date math overflow-free and refuse dates that never + // existed (Feb 31 used to normalize to Mar 3; huge years panicked). + if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) { + return None; + } + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let max_day = match mo { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }; + if !(1..=max_day).contains(&da) { return None; } let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') { @@ -550,6 +586,9 @@ pub fn parse_ts_secs(s: &str) -> Option { }; let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?; let om: i64 = rest[rest.len() - 2..].parse().ok()?; + if oh > 23 || om > 59 { + return None; + } (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60)) } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') { let sign = if rest.as_bytes()[rest.len() - 5] == b'-' { @@ -559,6 +598,9 @@ pub fn parse_ts_secs(s: &str) -> Option { }; let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?; let om: i64 = rest[rest.len() - 2..].parse().ok()?; + if oh > 23 || om > 59 { + return None; + } (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60)) } else { (rest, 0) @@ -1019,13 +1061,18 @@ pub fn clone_block( /// anchors (`#L12`, shareable), wrapped in a horizontal-scroll container /// so long lines never break the 360px layout. fn render_text_pre(display: &str, path: &str) -> String { + // Normalize line endings and drop exactly one trailing terminator: + // `split('\n')` used to turn a final newline into an empty L404 and + // kept `\r` in CRLF files (f0rge #88). + let normalized = display.replace("\r\n", "\n").replace('\r', "\n"); + let text = normalized.strip_suffix('\n').unwrap_or(&normalized); let mut out = String::from("
\n");
-    if display.is_empty() {
+    if text.is_empty() {
         out.push_str("1 ");
     } else {
         // Build-time tokens (f0rge #73): unknown/huge files stay plain.
-        let highlighted = crate::highlight::highlight_lines(path, display);
-        for (i, line) in display.split('\n').enumerate() {
+        let highlighted = crate::highlight::highlight_lines(path, text);
+        for (i, line) in text.split('\n').enumerate() {
             let n = i + 1;
             out.push_str("wiki".to_string());
     }
     tabs.push("agents".to_string());
-    tabs.join(" · ")
+    tabs.join("·")
 }
 
 /// Write `out/style.css` from the embedded static file.
@@ -1088,6 +1135,12 @@ fn write_style(out: &Path) -> Result<(), String> {
 /// Write `out/logo.svg` + `out/favicon.svg` + `out/manifest.webmanifest`
 /// from the embedded static files (m6 favicon #16; logo-text #27 + #28).
 /// SVG-first, zero binary blobs: the header lockup is `logo.svg`
+/// Pages Functions routing: only `/inbox/*` runs a Function; every
+/// other path stays a static asset. Must live in the build output, not
+/// the repo root (f0rge #79).
+pub const ROUTES_FILE: &str =
+    "{\n  \"version\": 1,\n  \"include\": [\"/inbox/*\"],\n  \"exclude\": []\n}\n";
+
 /// Header rules for Cloudflare Pages (and any host that reads `_headers`).
 ///
 /// `/raw/**` holds committed, untrusted bytes: a repo can contain
@@ -1120,11 +1173,12 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {
         .map_err(|e| format!("hu13: write manifest.webmanifest: {e}"))?;
     std::fs::write(out.join("_headers"), HEADERS_FILE)
         .map_err(|e| format!("hu13: write _headers: {e}"))?;
+    std::fs::write(out.join("_routes.json"), ROUTES_FILE)
+        .map_err(|e| format!("hu13: write _routes.json: {e}"))?;
     Ok(())
 }
 
-/// Absolute lexical form of `p` (no symlink resolution beyond what the
-/// OS already did for existing components).
+/// Absolute form of `p` (join cwd for relative paths, no symlink work).
 fn absolute(p: &Path) -> std::path::PathBuf {
     if p.is_absolute() {
         p.to_path_buf()
@@ -1135,15 +1189,81 @@ fn absolute(p: &Path) -> std::path::PathBuf {
     }
 }
 
+/// Absolute lexical form of `p` with `.`/`..` collapsed. Does not
+/// touch the filesystem (unlike canonicalize), so it also works for an
+/// output path that does not exist yet.
+fn normalize_lexical(p: &Path) -> std::path::PathBuf {
+    use std::path::Component;
+    let mut out = std::path::PathBuf::new();
+    for c in p.components() {
+        match c {
+            Component::CurDir => {}
+            Component::ParentDir => {
+                out.pop();
+            }
+            Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
+                out.push(c.as_os_str());
+            }
+        }
+    }
+    out
+}
+
+/// Resolve symlinks in the deepest existing ancestor, then append the
+/// not-yet-created suffix. This catches `-o alias/..` where `alias` is a
+/// symlink into the repo (f0rge #77).
+fn resolve_existing(p: &Path) -> std::path::PathBuf {
+    let abs = normalize_lexical(&absolute(p));
+    let mut suffix: Vec = Vec::new();
+    let mut cur = abs.as_path();
+    loop {
+        if let Ok(mut resolved) = cur.canonicalize() {
+            for part in suffix.iter().rev() {
+                resolved.push(part);
+            }
+            return normalize_lexical(&resolved);
+        }
+        match cur.file_name() {
+            Some(name) => {
+                suffix.push(name.to_os_string());
+                match cur.parent() {
+                    Some(parent) => cur = parent,
+                    None => return abs,
+                }
+            }
+            None => return abs,
+        }
+    }
+}
+
+/// `true` when git tracks at least one path under repo-relative `rel`.
+fn has_tracked_under(repo: &Path, rel: &Path) -> bool {
+    if rel.as_os_str().is_empty() {
+        return true;
+    }
+    std::process::Command::new("git")
+        .arg("-C")
+        .arg(repo)
+        .arg("ls-files")
+        .arg("-z")
+        .arg("--")
+        .arg(rel)
+        .output()
+        .map(|o| o.status.success() && !o.stdout.is_empty())
+        .unwrap_or(true)
+}
+
 /// Remove everything already in `out/` before a build (f0rge #67).
 ///
 /// Without this, pages for deleted issues/files keep being served from a
 /// reused output dir. Refuses to delete the repo itself or anything that
-/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir
-/// without following it.
+/// contains it (`-o ..`, `-o .`), resolves `.`/`..` and symlinked
+/// ancestors before comparing, and refuses an in-repo output that holds
+/// tracked files (f0rge #77).
 fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {
-    let out_abs = absolute(out);
-    let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));
+    let out_path = normalize_lexical(&absolute(out));
+    let out_abs = resolve_existing(&out_path);
+    let repo_abs = resolve_existing(repo);
     if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {
         return Err(format!(
             "hu13: refusing to clean output dir {}: it contains the repo",
@@ -1153,25 +1273,114 @@ fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {
     if out_abs.parent().is_none() {
         return Err("hu13: refusing to use the filesystem root as output dir".to_string());
     }
-    match std::fs::symlink_metadata(&out_abs) {
+    if let Ok(rel) = out_abs.strip_prefix(&repo_abs) {
+        if rel
+            .components()
+            .next()
+            .is_some_and(|c| c.as_os_str() == ".git")
+            || has_tracked_under(&repo_abs, rel)
+        {
+            return Err(format!(
+                "hu13: refusing to clean in-repo output dir {}: it holds tracked files",
+                out.display()
+            ));
+        }
+    }
+    match std::fs::symlink_metadata(&out_path) {
         Ok(meta) if meta.file_type().is_symlink() => {
-            std::fs::remove_file(&out_abs)
+            std::fs::remove_file(&out_path)
                 .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;
         }
         Ok(meta) if meta.is_dir() => {
-            std::fs::remove_dir_all(&out_abs)
+            std::fs::remove_dir_all(&out_path)
                 .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;
         }
         Ok(_) => {
-            std::fs::remove_file(&out_abs)
+            std::fs::remove_file(&out_path)
                 .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;
         }
         Err(_) => {}
     }
-    std::fs::create_dir_all(&out_abs)
+    std::fs::create_dir_all(&out_path)
         .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))
 }
 
+/// Make `og:image` absolute on every built page when a public origin is
+/// configured (f0rge #95). Done once over the output tree because the
+/// origin is build config, not page content: all pages share the same
+/// `content="/og.png"` marker.
+fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {
+    let base = origin.trim_end_matches('/');
+    if base.is_empty() {
+        return Ok(());
+    }
+    let absolute = format!("content=\"{}/og.png\"", esc(base));
+    let mut stack = vec![out.to_path_buf()];
+    while let Some(dir) = stack.pop() {
+        let rd = std::fs::read_dir(&dir)
+            .map_err(|e| format!("hu13: social meta read {}: {e}", dir.display()))?;
+        for ent in rd {
+            let ent = ent.map_err(|e| format!("hu13: social meta entry: {e}"))?;
+            let path = ent.path();
+            if path.is_dir() {
+                stack.push(path);
+            } else if path.extension().is_some_and(|x| x == "html") {
+                let html = std::fs::read_to_string(&path)
+                    .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;
+                let replaced = html.replace("content=\"/og.png\"", &absolute);
+                if replaced != html {
+                    std::fs::write(&path, replaced)
+                        .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Check an output tree against deployment limits. Returns the first
+/// offending file (or the file-count overflow) with enough context to
+/// fix the repo. Kept separate from the walk so tests can use tiny caps.
+pub fn check_output_budget(
+    out: &Path,
+    max_file_bytes: u64,
+    max_files: usize,
+) -> Result<(), String> {
+    let mut stack = vec![out.to_path_buf()];
+    let mut files = 0usize;
+    while let Some(dir) = stack.pop() {
+        let rd = std::fs::read_dir(&dir)
+            .map_err(|e| format!("hu13: output budget read {}: {e}", dir.display()))?;
+        for ent in rd {
+            let ent = ent.map_err(|e| format!("hu13: output budget entry: {e}"))?;
+            let path = ent.path();
+            let meta = ent
+                .metadata()
+                .map_err(|e| format!("hu13: output budget stat {}: {e}", path.display()))?;
+            if meta.is_dir() {
+                stack.push(path);
+            } else if meta.is_file() {
+                files += 1;
+                if files > max_files {
+                    return Err(format!(
+                        "hu13: output has more than {max_files} files (Cloudflare Pages limit);                          last: {}",
+                        path.display()
+                    ));
+                }
+                if meta.len() > max_file_bytes {
+                    return Err(format!(
+                        "hu13: output file {} is {} bytes, over the Cloudflare Pages limit of {} bytes;                          keep large assets out of git or use Git LFS",
+                        path.display(),
+                        meta.len(),
+                        max_file_bytes
+                    ));
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
 /// Build the full static site. Returns a summary on success.
 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result {
     let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())
@@ -1308,7 +1517,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultdir: {}\n", esc(dir),));
+        body.push_str(&format!("

{}/

\n", esc(dir))); body.push_str(&file_table_html( dir, &entries, @@ -1407,7 +1616,15 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Result404\n

Nothing here.

\n

Back to code

\n", + &name, + desc_opt, + &branch, + &short_sha, + ); + std::fs::write(out.join("404.html"), not_found) + .map_err(|e| format!("hu13: write 404.html: {e}"))?; + + if let Some(origin) = public_origin.as_deref() { + absolutize_social_meta(out, origin)?; + } + + // Fail loudly here instead of letting Pages reject an opaque deploy + // (f0rge #81). Counts the whole emitted tree, including repo.git. + check_output_budget(out, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES)?; + Ok(BuildSummary { branch, short_sha, @@ -2235,6 +2474,33 @@ mod tests { assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC"); } + #[test] + fn output_budget_reports_file_size_and_count() { + let dir = mktemp("budget"); + std::fs::write(dir.join("small.txt"), "ok").unwrap(); + std::fs::create_dir_all(dir.join("nested")).unwrap(); + std::fs::write(dir.join("nested/big.bin"), vec![0u8; 64]).unwrap(); + assert!(check_output_budget(&dir, 32, 10).is_err(), "size cap"); + assert!(check_output_budget(&dir, 1024, 1).is_err(), "count cap"); + assert!(check_output_budget(&dir, 1024, 2).is_ok(), "within caps"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn hostile_dates_are_rejected() { + // Hostile `.forge` timestamps must not panic (f0rge #82). + assert!(parse_ts_secs("100000000000000000-01-01T00:00:00Z").is_none()); + assert!(parse_ts_secs("-9223372036854775808-01-01T00:00:00Z").is_none()); + assert!(parse_ts_secs("0000-01-01T00:00:00Z").is_none()); + assert!(parse_ts_secs("10000-01-01T00:00:00Z").is_none()); + assert!(parse_ts_secs("2026-02-31T00:00:00Z").is_none()); + assert!(parse_ts_secs("2026-04-31T00:00:00Z").is_none()); + assert!(parse_ts_secs("2026-02-29T00:00:00Z").is_none()); + assert!(parse_ts_secs("2024-02-29T00:00:00Z").is_some()); + assert!(parse_ts_secs("2026-01-01T00:00:00+2500").is_none()); + assert!(parse_ts_secs("2026-01-01T00:00:00+0060").is_none()); + } + #[test] fn relative_age_fixed_clock() { // f0rge #33 rubric: fixed-clock fixture, build-time relative ages. diff --git a/src/feed.rs b/src/feed.rs index c6b99b6..24be85d 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -143,12 +143,31 @@ pub fn collect_entries_loaded(repo: &Path, log: &[gitx::Commit]) -> Result` (escaped; hostile markup /// renders as text). `None` omits `` entirely. #[must_use] +#[allow(dead_code)] pub fn render_feed( site_name: &str, branch: &str, entries: &[FeedEntry], description: Option<&str>, ) -> String { + render_feed_with_origin(site_name, branch, entries, description, None) +} + +/// [`render_feed`] with absolute entry links (f0rge #96): Atom readers +/// disagree on relative IRIs, so prefix `/...` links with the public +/// origin when one is configured. Already-absolute links stay untouched. +#[must_use] +pub fn render_feed_with_origin( + site_name: &str, + branch: &str, + entries: &[FeedEntry], + description: Option<&str>, + public_origin: Option<&str>, +) -> String { + let origin = public_origin + .map(str::trim) + .map(|s| s.trim_end_matches('/')) + .filter(|s| !s.is_empty()); let updated = entries .first() .map_or_else(|| "1970-01-01T00:00:00Z".to_string(), |e| esc(&e.updated)); @@ -161,12 +180,22 @@ pub fn render_feed( out.push_str(&format!("urn:hu13:feed:{}\n", esc(branch))); out.push_str(&format!("{updated}\n")); out.push_str("hu13\n"); + if let Some(base) = origin { + out.push_str(&format!( + "\n", + esc(base) + )); + } for e in entries { out.push_str("\n"); out.push_str(&format!("{}\n", esc(&e.title))); out.push_str(&format!("{}\n", esc(&e.id))); out.push_str(&format!("{}\n", esc(&e.updated))); - out.push_str(&format!("\n", esc(&e.link))); + let link = match origin { + Some(base) if e.link.starts_with('/') => format!("{base}{}", e.link), + _ => e.link.clone(), + }; + out.push_str(&format!("\n", esc(&link))); out.push_str(&format!("{}\n", esc(&e.summary))); out.push_str("\n"); } @@ -185,10 +214,11 @@ pub fn render( description: Option<&str>, ) -> Result { let entries = collect_entries(repo, branch)?; - render_entries(out, site_name, branch, &entries, description) + render_entries(out, site_name, branch, &entries, description, None) } /// Build `feed.atom` from already-loaded commits (one struct pass). +#[allow(dead_code)] pub fn render_loaded( repo: &Path, out: &Path, @@ -196,9 +226,23 @@ pub fn render_loaded( site_name: &str, log: &[gitx::Commit], description: Option<&str>, +) -> Result { + render_loaded_with_origin(repo, out, branch, site_name, log, description, None) +} + +/// [`render_loaded`] with absolute links for a configured public origin. +#[allow(clippy::too_many_arguments)] +pub fn render_loaded_with_origin( + repo: &Path, + out: &Path, + branch: &str, + site_name: &str, + log: &[gitx::Commit], + description: Option<&str>, + public_origin: Option<&str>, ) -> Result { let entries = collect_entries_loaded(repo, log)?; - render_entries(out, site_name, branch, &entries, description) + render_entries(out, site_name, branch, &entries, description, public_origin) } fn render_entries( @@ -207,8 +251,9 @@ fn render_entries( branch: &str, entries: &[FeedEntry], description: Option<&str>, + public_origin: Option<&str>, ) -> Result { - let doc = render_feed(site_name, branch, entries, description); + let doc = render_feed_with_origin(site_name, branch, entries, description, public_origin); std::fs::create_dir_all(out).map_err(|e| format!("hu13: create out dir: {e}"))?; std::fs::write(out.join("feed.atom"), doc) .map_err(|e| format!("hu13: write feed.atom: {e}"))?; diff --git a/src/forge.rs b/src/forge.rs index a9ada13..c9f7ce6 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -765,6 +765,26 @@ fn load_milestones(root: &Path) -> Vec { } } +/// Remove builder-local absolute paths from published CI logs (f0rge +/// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace +/// the canonical repo root with ``, then `$HOME` with `~`. +fn redact_paths(text: &str, root: &Path) -> String { + let mut out = text.to_string(); + let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + for p in [root_abs.as_path(), root] { + let needle = p.to_string_lossy(); + if !needle.is_empty() && needle != "/" { + out = out.replace(needle.as_ref(), ""); + } + } + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() && home != "/" { + out = out.replace(&home, "~"); + } + } + out +} + fn truncate_log(text: &str) -> String { if text.len() <= LOG_PREVIEW_MAX { return text.to_string(); @@ -778,6 +798,8 @@ fn truncate_log(text: &str) -> String { fn load_ci(root: &Path) -> CiInfo { let dir = root.join("ci"); + // `root` is `/.forge`; logs name the repo checkout itself. + let repo_root = root.parent().unwrap_or(root); let runner = if dir.join("ci.env").exists() { match read_text_capped(&dir.join("ci.env"), "ci/ci.env") { Some(text) => match yaml_serde::from_str::(&text) { @@ -817,7 +839,8 @@ fn load_ci(root: &Path) -> CiInfo { } let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name)) .unwrap_or_default(); - logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes)))); + let text = String::from_utf8_lossy(&bytes); + logs.push((name, truncate_log(&redact_paths(&text, repo_root)))); } Some(CiRun { run: fm.run, @@ -832,7 +855,7 @@ fn load_ci(root: &Path) -> CiInfo { duration_ms: s.duration_ms, }) .collect(), - body: body.to_string(), + body: redact_paths(body, repo_root), logs, }) }); @@ -1142,14 +1165,19 @@ fn render_issues_index( // non-open (newest-first within). Numbers stay creation-rank. // m6/cards2 (#46 + #47): title-first column order. for issue in data.issues_in_display_order() { + let state_plain = esc(&issue.state); + let state_html = state_pill(&issue.state); + let created_plain = time_ago(&issue.created, now); body.push_str(&format!( - "{title}{n}{state}{comments}{created}\n", + "{title}{n}{state_html}{comments}{created_html}\n", n = issue.number, id = esc(&issue.id), title = esc(&issue.title), - state = state_pill(&issue.state), + state_plain = state_plain, + state_html = state_html, comments = issue.comments.len(), - created = time_ago(&issue.created, now), + created = esc(&created_plain), + created_html = created_plain, )); } body.push_str("\n
\n"); @@ -1207,19 +1235,28 @@ fn render_pulls_index( // Numbers stay creation-rank. // m6/cards2 (#46 + #47): title-first column order. for pr in data.prs_in_display_order() { - let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes = format!( + "{}→{}", + esc(&pr.head), + esc(&pr.base) + ); let lanes_plain = format!("{}→{}", pr.head, pr.base); let draft = if pr.draft { " draft" } else { "" }; let full_title = format!("{}{}", pr.title, draft); + let state_plain = esc(&pr.state); + let state_html = state_pill(&pr.state); + let created_plain = time_ago(&pr.created, now); body.push_str(&format!( - "{title}{draft}{n}{state}{lanes}{created}\n", + "{title}{draft}{n}{state_html}{lanes}{created_html}\n", n = pr.number, id = esc(&pr.id), title = esc(&pr.title), full = esc(&full_title), - state = state_pill(&pr.state), + state_plain = state_plain, + state_html = state_html, lanes_title = esc(&lanes_plain), - created = time_ago(&pr.created, now), + created = esc(&created_plain), + created_html = created_plain, )); } body.push_str("\n\n"); diff --git a/src/highlight.rs b/src/highlight.rs index 5a79051..65c885d 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -235,48 +235,74 @@ pub fn highlight_lines(path: &str, text: &str) -> Option> { Some(out) } +fn starts_with_at(chars: &[char], at: usize, pat: &str) -> bool { + for (i, c) in (at..).zip(pat.chars()) { + if chars.get(i) != Some(&c) { + return false; + } + } + true +} + +/// Find `pat` at or after char index `from`, without building a +/// `String` for the rest of the line (the old scanner did that once per +/// character, which made minified files quadratic; f0rge #78). +fn find_at(chars: &[char], from: usize, pat: &str) -> Option { + let len = pat.chars().count(); + if len == 0 { + return Some(from); + } + let last = chars.len().checked_sub(len)?; + if from > last { + return None; + } + (from..=last).find(|&i| starts_with_at(chars, i, pat)) +} + +fn slice(chars: &[char], from: usize, to: usize) -> String { + chars[from..to].iter().collect() +} + fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String { let chars: Vec = line.chars().collect(); let mut out = String::with_capacity(line.len() + 16); let mut i = 0; while i < chars.len() { - let rest: String = chars[i..].iter().collect(); - if *in_block { if let Some((_, end)) = lang.block { - if let Some(pos) = rest.find(end) { - let stop = i + rest[..pos].chars().count() + end.chars().count(); - let piece: String = chars[i..stop].iter().collect(); - push_span(&mut out, "com", &piece); + if let Some(end_at) = find_at(&chars, i, end) { + let stop = end_at + end.chars().count(); + push_span(&mut out, "com", &slice(&chars, i, stop)); *in_block = false; i = stop; continue; } } - let piece: String = chars[i..].iter().collect(); - push_span(&mut out, "com", &piece); + push_span(&mut out, "com", &slice(&chars, i, chars.len())); return out; } if let Some((start, end)) = lang.block { - if let Some(after_start) = rest.strip_prefix(start) { - if let Some(pos) = after_start.find(end) { - let stop = i + rest[..pos + start.len()].chars().count() + end.chars().count(); - let piece: String = chars[i..stop].iter().collect(); - push_span(&mut out, "com", &piece); + if starts_with_at(&chars, i, start) { + let after_start = i + start.chars().count(); + if let Some(end_at) = find_at(&chars, after_start, end) { + let stop = end_at + end.chars().count(); + push_span(&mut out, "com", &slice(&chars, i, stop)); i = stop; continue; } *in_block = true; - let piece: String = chars[i..].iter().collect(); - push_span(&mut out, "com", &piece); + push_span(&mut out, "com", &slice(&chars, i, chars.len())); return out; } } - if lang.line_comment.iter().any(|m| rest.starts_with(m)) { - let piece: String = chars[i..].iter().collect(); - push_span(&mut out, "com", &piece); + if lang + .line_comment + .iter() + .any(|m| starts_with_at(&chars, i, m)) + { + push_span(&mut out, "com", &slice(&chars, i, chars.len())); return out; } @@ -296,8 +322,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String { } j += 1; } - let piece: String = chars[i..j].iter().collect(); - push_span(&mut out, "str", &piece); + push_span(&mut out, "str", &slice(&chars, i, j)); i = j; continue; } @@ -309,8 +334,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String { { j += 1; } - let piece: String = chars[i..j].iter().collect(); - push_span(&mut out, "num", &piece); + push_span(&mut out, "num", &slice(&chars, i, j)); i = j; continue; } @@ -320,7 +344,7 @@ fn scan_line(lang: &Lang, line: &str, in_block: &mut bool) -> String { while j < chars.len() && is_ident(chars[j]) { j += 1; } - let word: String = chars[i..j].iter().collect(); + let word = slice(&chars, i, j); let next_non_space = chars[j..].iter().find(|c| !c.is_whitespace()); let call = next_non_space.is_some_and(|c| *c == '('); let class = if lang.keywords.contains(&word.as_str()) { @@ -395,6 +419,17 @@ mod tests { assert!(lines[0].contains("<script>")); } + #[test] + fn long_single_line_is_not_quadratic() { + // 64 KiB on one line took ~27s with the per-char suffix clone; + // the linear scanner stays far below this coarse bound (f0rge #78). + let big = "var x=1;".repeat(8000); + let start = std::time::Instant::now(); + let lines = highlight_lines("min.js", &big).expect("js"); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); + assert!(!lines[0].is_empty()); + } + #[test] fn oversized_inputs_stay_plain() { let big = "x".repeat(MAX_HIGHLIGHT_BYTES + 1); diff --git a/src/layout.rs b/src/layout.rs index 20480b7..b2e29e5 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -57,10 +57,13 @@ pub fn diff_html(raw: &str) -> String { let path = diff_chunk_path(chunk); let mut added = 0usize; let mut removed = 0usize; + let mut counting_hunk = false; for line in chunk { - if line.starts_with('+') && !line.starts_with("+++") { + if line.starts_with("@@") { + counting_hunk = true; + } else if line.starts_with('+') && (counting_hunk || !line.starts_with("+++")) { added += 1; - } else if line.starts_with('-') && !line.starts_with("---") { + } else if line.starts_with('-') && (counting_hunk || !line.starts_with("---")) { removed += 1; } } @@ -82,13 +85,10 @@ pub fn diff_html(raw: &str) -> String { let mut old_ln = 0i64; let mut new_ln = 0i64; - let mut first = true; + let mut in_hunk = false; for line in chunk { - if !first { - out.push('\n'); - } - first = false; if line.starts_with("@@") { + in_hunk = true; let mut parts = line.split_whitespace(); let _ = parts.next(); if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) { @@ -106,10 +106,22 @@ pub fn diff_html(raw: &str) -> String { .unwrap_or(0); } out.push_str(&format!("{}", esc(line))); - } else if line.starts_with('+') && !line.starts_with("+++") { + } else if line.starts_with("+++") || line.starts_with("---") { + if in_hunk { + if line.starts_with('+') { + out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line)); + new_ln += 1; + } else { + out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line)); + old_ln += 1; + } + } else { + out.push_str(&format!("{}", esc(line))); + } + } else if line.starts_with('+') { out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line)); new_ln += 1; - } else if line.starts_with('-') && !line.starts_with("---") { + } else if line.starts_with('-') { out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line)); old_ln += 1; } else if line.starts_with(' ') { @@ -121,8 +133,6 @@ pub fn diff_html(raw: &str) -> String { )); old_ln += 1; new_ln += 1; - } else if line.starts_with("+++") || line.starts_with("---") { - out.push_str(&format!("{}", esc(line))); } else { out.push_str(&format!("{}", esc(line))); } @@ -142,14 +152,15 @@ fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String { /// Path for one chunk: `+++ b/

` first, then `--- a/

`, then the /// `diff --git` header. `/dev/null` falls through to the other side. fn diff_chunk_path(lines: &[&str]) -> String { - for line in lines { + let headers = lines.iter().take_while(|l| !l.starts_with("@@")); + for line in headers.clone() { if let Some(rest) = line.strip_prefix("+++ ") { if rest != "/dev/null" { return rest.strip_prefix("b/").unwrap_or(rest).to_string(); } } } - for line in lines { + for line in headers { if let Some(rest) = line.strip_prefix("--- ") { if rest != "/dev/null" { return rest.strip_prefix("a/").unwrap_or(rest).to_string(); @@ -168,16 +179,12 @@ fn diff_chunk_path(lines: &[&str]) -> String { /// with line-level coloring (markers stay span-free, old behavior). fn plain_diff_html(raw: &str) -> String { let mut out = String::with_capacity(raw.len() + raw.len() / 4); - let mut first = true; for line in raw.lines() { - if !first { - out.push('\n'); - } - first = false; + // One block span per line; no literal newlines between spans or + // `white-space:pre` inserts a blank row (f0rge #86). + out.push_str(""); out.push_str(&diff_line_span(line)); - } - if raw.ends_with('\n') { - out.push('\n'); + out.push_str(""); } out } @@ -322,7 +329,7 @@ pub fn provenance_footer(branch: Option<&str>, short_sha: Option<&str>) -> Strin } } format!( - "

" + "" ) } @@ -644,6 +651,42 @@ mod tests { assert!(!html.contains(" line one"), "context has no span"); } + #[test] + fn diff_rows_have_no_blank_lines_and_content_headers_count() { + // f0rge #86: no literal newline between block rows. + // f0rge #87: `++++` after a hunk is added content, not a header. + let raw = "diff --git a/a.txt b/a.txt\n\ +--- a/a.txt\n+++ b/a.txt\n\ +@@ -1 +1,4 @@\n context\n\ +++++ not a header\n+--- not a header\n----- removed content\n+plain\n"; + let html = diff_html(raw); + assert!( + !html.contains("\n+3"), + "three added lines, including +++ content: {html}" + ); + assert!( + html.contains("−1"), + "one removed line, including --- content: {html}" + ); + assert!( + html.contains("++++ not a header"), + "added +++ content keeps its marker and is not a file header: {html}" + ); + assert!( + html.contains("----- removed content"), + "removed --- content is a removal: {html}" + ); + assert_eq!( + html.matches("").count(), + 2, + "only the real ---/+++ file headers" + ); + } + #[test] fn diff_html_escapes_before_wrapping() { let html = diff_html("+evil \n-old & \"quoted\"\n"); diff --git a/src/serve.rs b/src/serve.rs index 12718d1..85df371 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -485,7 +485,9 @@ const RAW_ACTIVE_EXTS: &[&str] = &[ "pdf", "swf", "hta", "jar", "crx", "xpi", ]; -/// Text-ish extensions safe to show inline from `/raw/`. +/// Text-ish extensions that keep a text MIME type. They still download: +/// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses +/// the same disposition instead of rendering text inline (f0rge #93). const RAW_TEXT_EXTS: &[&str] = &[ "txt", "text", @@ -550,14 +552,14 @@ fn extension(name: &str) -> String { /// /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run /// script in the site's origin (where the inbox endpoints live). Active -/// types become an octet-stream download; text stays inline as -/// `text/plain`; everything else downloads. +/// types become an octet-stream download; text keeps `text/plain` but +/// also downloads; everything downloads with `attachment` (f0rge #93). fn raw_content_type(name: &str) -> (&'static str, &'static str) { let ext = extension(name); if RAW_ACTIVE_EXTS.contains(&ext.as_str()) { ("application/octet-stream", "attachment") } else if RAW_TEXT_EXTS.contains(&ext.as_str()) { - ("text/plain; charset=utf-8", "inline") + ("text/plain; charset=utf-8", "attachment") } else { ("application/octet-stream", "attachment") } @@ -706,13 +708,17 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { }; let dest = absolutize(&shared.www.join(&file_rel)); if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) { - static_error(&mut stream, 404); + static_not_found(&mut stream, &head, shared); return; } - let bytes = match std::fs::read(&dest) { - Ok(b) if dest.is_file() => b, + // Stat, then stream. Never read a static file into memory: a raw + // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The + // method check stays after existence so an unknown POST is still + // 404 (fail closed, no route discovery). + let meta = match std::fs::metadata(&dest) { + Ok(m) if m.is_file() => m, _ => { - static_error(&mut stream, 404); + static_not_found(&mut stream, &head, shared); return; } }; @@ -732,22 +738,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { &[] }; if head.method == "HEAD" { - let mut out = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n", - bytes.len() - ); - for (k, v) in extra { - out.push_str(k); - out.push_str(": "); - out.push_str(v); - out.push_str("\r\n"); - } - out.push_str("\r\n"); - let _ = stream.write_all(out.as_bytes()); - let _ = stream.flush(); + respond_file_head(&mut stream, 200, ctype, extra, meta.len()); return; } - respond(&mut stream, 200, ctype, extra, &bytes); + respond_file(&mut stream, 200, ctype, extra, &dest, meta.len()); } /// Read-and-discard in-flight request bytes (bounded) so early rejects @@ -800,13 +794,12 @@ impl Drop for GitSlot { } } -/// Stream a file body with `Content-Length` (no full-pack buffering). -fn respond_file( +/// Headers for a file response, without opening the file body. +fn respond_file_head( stream: &mut TcpStream, code: u16, content_type: &str, extra: &[(&str, &str)], - path: &Path, len: u64, ) { let mut out = format!( @@ -820,9 +813,51 @@ fn respond_file( out.push_str("\r\n"); } out.push_str("\r\n"); - if stream.write_all(out.as_bytes()).is_err() { + let _ = stream.write_all(out.as_bytes()); + let _ = stream.flush(); +} + +/// Serve the built `404.html` (same chrome as every page) with status +/// 404. Falls back to the tiny in-binary stub when the file is absent. +/// POSTs keep the old non-echoing stub (`f0rge #85`). +fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) { + if head.method != "GET" && head.method != "HEAD" { + static_error(stream, 404); return; } + let dest = shared.www.join("404.html"); + let Ok(meta) = std::fs::metadata(&dest) else { + static_error(stream, 404); + return; + }; + if !meta.is_file() { + static_error(stream, 404); + return; + } + if head.method == "HEAD" { + respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len()); + } else { + respond_file( + stream, + 404, + "text/html; charset=utf-8", + &[], + &dest, + meta.len(), + ); + } +} + +/// Stream a file body with `Content-Length` (no full-pack buffering). +fn respond_file( + stream: &mut TcpStream, + code: u16, + content_type: &str, + extra: &[(&str, &str)], + path: &Path, + len: u64, +) { + respond_file_head(stream, code, content_type, extra, len); if let Ok(mut f) = std::fs::File::open(path) { let _ = std::io::copy(&mut f, stream); } diff --git a/static/style.css b/static/style.css index 95af580..471097d 100644 --- a/static/style.css +++ b/static/style.css @@ -17,6 +17,7 @@ --yellow:#e0af68; --red:#f7768e; --border:#292e42; + --field-border:#6a7590; --visited:#9aa5ce; --btn-fg:#16161e; --state-open:#9ece6a; @@ -66,6 +67,7 @@ --yellow:#7d5f33; --red:#8c4351; --border:#d6dae2; + --field-border:#8a93a5; --visited:#5a4a78; --btn-fg:#ffffff; --state-open:#33635c; @@ -77,9 +79,9 @@ --tok-com:#4a5273; --tok-fn:#34548a; --tok-type:#0f4b6e; - --diff-add:#33635c; + --diff-add:#2a534d; --diff-add-bg:#c6d4ca; - --diff-del:#8c4351; + --diff-del:#7a3543; --diff-del-bg:#d9c2c7; --diff-hunk:#0f4b6e; --diff-file:#5a4a78; @@ -141,8 +143,8 @@ td.num{width:1%;white-space:nowrap} /* Forms: full-width on mobile, >=40px touch targets. */ form{max-width:44rem;margin:1rem 0;min-width:0} label{display:block;margin:.75rem 0 .25rem;font-weight:600} -input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--bg-alt); - color:var(--fg);border:1px solid var(--border);padding:.5rem .6rem;font:inherit;min-height:40px} +input[type=text],textarea{display:block;width:100%;max-width:100%;background:var(--surface); + color:var(--fg);border:1px solid var(--field-border);padding:.5rem .6rem;font:inherit;min-height:40px} input[type=text]:focus,textarea:focus{outline:2px solid var(--blue);outline-offset:0} textarea{min-height:12rem} button,input[type=submit]{display:inline-flex;align-items:center;justify-content:center; @@ -193,6 +195,7 @@ footer.site-footer .footer-provenance{margin:0;font-size:var(--fs-sm);text-align .site-logo{display:block;width:auto;height:1.75rem;flex:none} .hu13-word{font-weight:700;color:var(--blue)} .sep{flex:none;color:var(--muted)} +.nav-sep{flex:0 0 auto;color:var(--muted)} .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} .nav-scroll-row{display:flex;align-items:center;gap:.25rem 1rem;white-space:nowrap;width:max-content;min-width:100%} .site-header .site-nav.nav-scroll a{flex:0 0 auto;white-space:nowrap} @@ -362,6 +365,7 @@ time{font-variant-numeric:tabular-nums} .df-bar-add{display:block;height:100%;background:var(--green)} .difffile .pre-scroll{margin:0} pre.diff{margin:0;border:0;background:var(--surface)} +pre.diff>span{display:block} .df-line{display:block} .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} .dln+.dln{border-right:1px solid var(--border);margin-right:.5em} @@ -370,11 +374,26 @@ pre.diff{margin:0;border:0;background:var(--surface)} .diff-file{color:var(--diff-file);font-weight:600} .df-meta{color:var(--fg-dim)} +/* Tablet nav (f0rge #91): 768–1099px scrolls off the last tab with no + affordance. Give the nav its own wrapped row until the full one-row + header fits at 1100px; separators would otherwise begin a line. */ +@media (min-width:768px) and (max-width:1099px){ + .site-header .wrap.header-bar{flex-wrap:wrap} + .site-header .sep,.site-header .nav-sep{display:none} + .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)} + .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem} +} +/* Branch lanes (f0rge #92): never break inside a branch name; wrap only + between head, arrow and base (a very long token scrolls its cell). + Mobile card cells inherit word-break:break-all, so reset it here. */ +.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} +.lanes .lane{white-space:nowrap} +.lanes .lane-arrow{color:var(--muted)} /* Responsive fixes (f0rge #75): every nav destination visible at 390px, file table keeps Name + Age, meta chips never overflow. */ @media (max-width:767px){ .site-header .wrap.header-bar{flex-wrap:wrap} - .site-header .sep{display:none} + .site-header .sep,.site-header .nav-sep{display:none} .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)} .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem} /* Dense file table: drop "Last commit" below 768px instead of wrapping diff --git a/tests/clean_out.rs b/tests/clean_out.rs index fdc1c03..1d56d90 100644 --- a/tests/clean_out.rs +++ b/tests/clean_out.rs @@ -122,3 +122,61 @@ fn refuses_to_clean_repo_or_its_parent() { assert!(sender.is_file(), "repo untouched"); } } + +#[test] +fn refuses_relative_dotdot_output_and_keeps_parent() { + let root = fresh_dir("dotdot"); + let repo = root.join("repo"); + std::fs::create_dir_all(repo.join("src")).unwrap(); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.join("README.md"), "# keep\n").unwrap(); + std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let canary = root.join("canary.txt"); + std::fs::write(&canary, "must survive\n").unwrap(); + + let res = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("build") + .arg(&repo) + .arg("-o") + .arg("..") + .current_dir(&repo) + .output() + .expect("spawn hu13 build"); + assert!(!res.status.success(), "relative .. must be refused"); + let err = String::from_utf8_lossy(&res.stderr); + assert!(err.contains("refusing"), "stderr: {err}"); + assert!(canary.is_file(), "parent canary must survive"); + assert!(repo.join("README.md").is_file(), "repo must survive"); +} + +#[cfg(unix)] +#[test] +fn refuses_symlinked_ancestor_into_repo() { + let root = fresh_dir("symlink"); + let repo = root.join("repo"); + std::fs::create_dir_all(repo.join("src")).unwrap(); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.join("README.md"), "# keep\n").unwrap(); + std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let alias = root.join("alias"); + std::os::unix::fs::symlink(&repo, &alias).unwrap(); + + let res = build(&repo, &alias.join("src")); + assert!(!res.status.success(), "symlinked src must be refused"); + let err = String::from_utf8_lossy(&res.stderr); + assert!(err.contains("refusing"), "stderr: {err}"); + assert!( + repo.join("src/main.rs").is_file(), + "tracked source must survive" + ); +} diff --git a/tests/light_theme.rs b/tests/light_theme.rs index 42f309b..575c1a9 100644 --- a/tests/light_theme.rs +++ b/tests/light_theme.rs @@ -167,7 +167,7 @@ fn no_hardcoded_palette_hex_outside_root() { "#f7768e", "#292e42", "#1f2335", "#9aa5ce", "#f6f7f9", "#343b58", "#34548a", "#0f4b6e", "#8c4351", "#33635c", "#eceef2", "#e4e7ee", "#5a4a78", "#7d5f33", "#ffffff", "#233328", "#38232e", "#c6d4ca", "#d9c2c7", "#d6dae2", "#4a5273", "#1f2335", "#24283b", "#bb9af7", - "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", + "#ff9e64", "#7f87ad", "#2ac3de", "#8c6c3e", "#2a534d", "#7a3543", "#6a7590", "#8a93a5", ]; for hex in palette { assert!( @@ -200,8 +200,8 @@ fn diff_vars_defined_dark_and_light() { "--diff-add:#9ece6a", "--diff-del:#f7768e", "--diff-hunk:#7dcfff", - "--diff-add:#33635c", - "--diff-del:#8c4351", + "--diff-add:#2a534d", + "--diff-del:#7a3543", "--diff-hunk:#0f4b6e", ] { assert!(css.contains(var), "diff var defined: {var}"); diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs new file mode 100644 index 0000000..f9625c8 --- /dev/null +++ b/tests/pages_inbox.test.mjs @@ -0,0 +1,159 @@ +// Cloudflare Pages inbox parity tests (f0rge #79). Run: +// node --test tests/pages_inbox.test.mjs +// No network, no wrangler: a fake R2 bucket exercises validation, +// origin gate, ack, storage shape, rate and quota. +import test from "node:test"; +import assert from "node:assert/strict"; +import { onRequestGet, onRequestPost } from "../functions/inbox/[kind].js"; + +class FakeBucket { + constructor() { + this.objects = new Map(); + } + async list({ limit = 1000 } = {}) { + return { + objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })), + truncated: this.objects.size > limit, + }; + } + async put(key, value) { + this.objects.set(key, String(value)); + } +} + +let seq = 0; +function env(extra = {}) { + return { + INBOX: new FakeBucket(), + INBOX_SALT: "test-salt", + ...extra, + }; +} + +function post(kind, body, headers = {}, e = env()) { + const request = new Request(`https://hu13.test/inbox/${kind}`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "cf-connecting-ip": `203.0.113.${++seq}`, + ...headers, + }, + body, + }); + return onRequestPost({ request, env: e, params: { kind } }).then(async (res) => ({ + res, + text: await res.text(), + env: e, + })); +} + +const validIssue = "title=Hello&body=World&license=agree"; +const validPrompt = "title=Prompt&prompt=Do+the+thing&license=on"; + +test("GET is 405 with POST allowed and no-store/nosniff", async () => { + const res = await onRequestGet(); + assert.equal(res.status, 405); + assert.equal(res.headers.get("allow"), "POST"); + assert.equal(res.headers.get("x-content-type-options"), "nosniff"); + assert.equal(res.headers.get("cache-control"), "no-store"); +}); + +test("valid issue stores one JSON object and returns an ack", async () => { + const e = env(); + const { res, text } = await post("issue", validIssue, {}, e); + assert.equal(res.status, 200); + assert.match(text, /ack: [0-9a-f-]{36}<\/code>/); + assert.equal(e.INBOX.objects.size, 1); + const [key, value] = [...e.INBOX.objects.entries()][0]; + assert.match(key, /^[0-9a-f-]{36}\.json$/); + const doc = JSON.parse(value); + assert.equal(doc.type, "issue"); + assert.equal(doc.title, "Hello"); + assert.equal(doc.body, "World"); + assert.equal(doc.license_version, "v1"); + assert.match(doc.received_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + assert.match(doc.salted_ip_hash, /^[0-9a-f]{64}$/); + assert.ok(!text.includes("World"), "ack never reflects input"); +}); + +test("valid pr-prompt uses prompt (and accepts body alias)", async () => { + for (const body of [validPrompt, "title=P&body=alias&license=yes"]) { + const e = env(); + const { res } = await post("pr-prompt", body, {}, e); + assert.equal(res.status, 200); + const doc = JSON.parse([...e.INBOX.objects.values()][0]); + assert.equal(doc.type, "pr-prompt"); + assert.equal(doc.body, body.includes("prompt") ? "Do the thing" : "alias"); + } +}); + +test("missing license, empty fields and oversize are 400", async () => { + for (const body of [ + "title=Hello&body=World", + "title=&body=World&license=agree", + "title=Hello&body=&license=agree", + `title=${"x".repeat(301)}&body=W&license=agree`, + ]) { + const { res } = await post("issue", body); + assert.equal(res.status, 400, body); + } +}); + +test("honeypot fakes success without storing", async () => { + const e = env(); + const { res, text } = await post( + "issue", + "title=Hello&body=World&license=agree&website=bot", + {}, + e, + ); + assert.equal(res.status, 200); + assert.match(text, /ack: /); + assert.equal(e.INBOX.objects.size, 0); +}); + +test("malformed escapes, non-UTF-8 and NUL are 400", async () => { + for (const body of [ + "title=%ZZ&body=W&license=agree", + "title=%FF&body=W&license=agree", + "title=He%00llo&body=W&license=agree", + ]) { + const { res } = await post("issue", body); + assert.equal(res.status, 400, body); + } +}); + +test("cross-origin browser POST is rejected; curl (no Origin) passes", async () => { + const bad = await post("issue", validIssue, { origin: "https://evil.test" }); + assert.equal(bad.res.status, 403); + const nullOrigin = await post("issue", validIssue, { origin: "null" }); + assert.equal(nullOrigin.res.status, 403); + const same = await post("issue", validIssue, { origin: "https://hu13.test" }); + assert.equal(same.res.status, 200); +}); + +test("wrong content type and oversized bodies are rejected", async () => { + const json = await post("issue", validIssue, { + "content-type": "application/json", + }); + assert.equal(json.res.status, 400); + const big = `title=Hello&body=${"x".repeat(33 * 1024)}&license=agree`; + const over = await post("issue", big); + assert.equal(over.res.status, 413); +}); + +test("quota is enforced before storing", async () => { + const e = env({ INBOX_QUOTA: "1" }); + const first = await post("issue", validIssue, {}, e); + assert.equal(first.res.status, 200); + const second = await post("issue", validIssue, {}, e); + assert.equal(second.res.status, 503); + assert.equal(e.INBOX.objects.size, 1); +}); + +test("missing R2 binding or salt fails closed", async () => { + const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" }); + assert.equal(noBucket.res.status, 503); + const noSalt = await post("issue", validIssue, {}, { INBOX: new FakeBucket() }); + assert.equal(noSalt.res.status, 503); +}); diff --git a/tests/raw_safety.rs b/tests/raw_safety.rs index 349a1b5..378cf50 100644 --- a/tests/raw_safety.rs +++ b/tests/raw_safety.rs @@ -147,7 +147,11 @@ fn raw_active_types_download_never_render() { Some("text/plain; charset=utf-8"), "{path}" ); - assert_eq!(header(&resp, "Content-Disposition"), Some("inline")); + assert_eq!( + header(&resp, "Content-Disposition"), + Some("attachment"), + "{path} downloads, matching _headers" + ); } let resp = get(&srv.addr, "/raw/blob.bin"); diff --git a/tests/review3_render.rs b/tests/review3_render.rs new file mode 100644 index 0000000..65b7d2e --- /dev/null +++ b/tests/review3_render.rs @@ -0,0 +1,264 @@ +//! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer, +//! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social +//! and feed URLs, dir headings. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +static SEQ: AtomicU64 = AtomicU64::new(0); + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-review3-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed))) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +fn write(repo: &Path, rel: &str, body: &str) { + let dest = repo.join(rel); + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap(); + std::fs::write(dest, body).unwrap(); +} + +const ISSUE: &str = "11111111-1111-4111-8111-111111111111"; +const PR: &str = "44444444-4444-4444-8444-444444444444"; + +fn fixture() -> PathBuf { + let repo = fresh("repo"); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + write(&repo, "README.md", "# review fixture\n"); + write(&repo, "a#b.txt", "hash name\n"); + write(&repo, "q?x.txt", "query name\n"); + write(&repo, "src/hi.txt", "subdir\n"); + write(&repo, "crlf.txt", "a\r\nb\r\n"); + write( + &repo, + &format!(".forge/issues/{ISSUE}/issue.md"), + &format!( + "---\nid: {ISSUE}\ntitle: Closed issue\nstate: closed\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n" + ), + ); + write( + &repo, + &format!(".forge/prs/{PR}/pr.md"), + &format!( + "---\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" + ), + ); + write( + &repo, + ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md", + "---\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", + ); + write( + &repo, + ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log", + &format!("Checking hu13 at {}\nfinished\n", repo.display()), + ); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + repo +} + +fn build(repo: &Path, out: &Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("build") + .arg(repo) + .arg("-o") + .arg(out) + .arg("--public-origin") + .arg("https://hu13.example") + .output() + .expect("spawn hu13 build") +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +#[test] +fn review3_build_regressions() { + let repo = fixture(); + let out = fresh("out"); + let res = build(&repo, &out); + assert!( + res.status.success(), + "build: {}", + String::from_utf8_lossy(&res.stderr) + ); + + let index = read(&out.join("index.html")); + assert!( + index.contains("href=\"/files/a%23b.txt.html\""), + "hash filename is percent-encoded in links" + ); + assert!( + index.contains("href=\"/files/q%3Fx.txt.html\""), + "query filename is percent-encoded in links" + ); + assert!(!index.contains("href=\"/files/a#b.txt.html\"")); + assert!( + index.contains("content=\"https://hu13.example/og.png\""), + "og:image is absolute with --public-origin" + ); + assert!( + index.contains("href=\"/new-issue.html\">submit"), + "footer submit points at the form, not POST-only /inbox/issue" + ); + + let hash_page = read(&out.join("files/a#b.txt.html")); + assert!(hash_page.contains("href=\"/raw/a%23b.txt\"")); + let crlf = read(&out.join("files/crlf.txt.html")); + assert!(crlf.contains("id=\"L2\""), "two lines survive"); + assert!( + !crlf.contains("id=\"L3\""), + "no phantom L404 after trailing CRLF" + ); + assert!( + !crlf.contains(" ") && !crlf.contains('\r'), + "CR stripped" + ); + + let dir_page = read(&out.join("files/src.html")); + assert!(dir_page.contains("

src/

")); + assert!(!dir_page.contains("dir: src"), "debug heading removed"); + + let issues = read(&out.join("issues/index.html")); + let pulls = read(&out.join("pulls/index.html")); + for page in [&issues, &pulls] { + assert!(!page.contains("title=\"<"), "no HTML in title attributes"); + assert!( + !page.contains("
\">"), + "no leaked span attribute text" + ); + assert!( + !page.contains("\">"), + "no leaked time attribute text" + ); + } + assert!(issues.contains("title=\"closed\""), "plain state title"); + assert!(pulls.contains("class=\"lane\""), "branch lanes are tokens"); + + let ci = read(&out.join("ci/index.html")); + assert!( + !ci.contains(&repo.display().to_string()), + "builder path redacted from CI logs" + ); + assert!( + ci.contains("<repo>") || ci.contains(""), + "redaction marker" + ); + + let feed = read(&out.join("feed.atom")); + assert!( + feed.contains(""), + "feed self link absolute" + ); + assert!( + feed.contains("href=\"https://hu13.example/commit/"), + "entry links absolute" + ); + + let not_found = read(&out.join("404.html")); + assert!(not_found.contains("

404

")); + assert!(not_found.contains("site-footer"), "branded 404 has footer"); + assert!(out.join("_routes.json").is_file(), "Pages routes emitted"); + let routes = read(&out.join("_routes.json")); + assert!(routes.contains("\"/inbox/*\"")); +} + +struct Server { + child: Child, + addr: String, +} + +impl Drop for Server { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +fn start(www: &Path, inbox: &Path) -> Server { + let mut child = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("serve") + .arg("--www") + .arg(www) + .arg("--inbox-dir") + .arg(inbox) + .arg("--listen") + .arg("127.0.0.1:0") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve"); + let stdout = child.stdout.take().expect("stdout"); + let mut addr = String::new(); + for line in BufReader::new(stdout).lines().take(50).flatten() { + if let Some(rest) = line.split("listening on ").nth(1) { + addr = rest.trim().to_string(); + break; + } + } + assert!(!addr.is_empty(), "serve printed no address"); + Server { child, addr } +} + +fn get(addr: &str, path: &str) -> String { + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + write!( + s, + "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut out = Vec::new(); + s.read_to_end(&mut out).unwrap(); + String::from_utf8_lossy(&out).into_owned() +} + +#[test] +fn serve_uses_branded_404_and_streams_encoded_paths() { + let repo = fixture(); + let out = fresh("serve-out"); + let res = build(&repo, &out); + assert!(res.status.success()); + let inbox = fresh("inbox"); + let srv = start(&out, &inbox); + std::thread::sleep(Duration::from_millis(100)); + + let missing = get(&srv.addr, "/definitely-not-here"); + assert!(missing.starts_with("HTTP/1.1 404"), "{missing}"); + assert!(missing.contains("

404

")); + assert!(missing.contains("class=\"site-footer\""), "same chrome"); + + let encoded = get(&srv.addr, "/files/a%23b.txt.html"); + assert!(encoded.starts_with("HTTP/1.1 200"), "{encoded}"); + let raw = get(&srv.addr, "/raw/a%23b.txt"); + assert!(raw.starts_with("HTTP/1.1 200"), "{raw}"); + assert!(raw.contains("Content-Disposition: attachment")); +} diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rs index 0c0fea3..d3ae23d 100644 --- a/tests/theme_contrast.rs +++ b/tests/theme_contrast.rs @@ -119,6 +119,8 @@ fn both_themes_pass_aa_for_text_colors() { ("--tok-type", "--surface"), ("--diff-file", "--surface"), ("--diff-hunk", "--surface"), + ("--diff-add", "--diff-add-bg"), + ("--diff-del", "--diff-del-bg"), ]; check("dark", dark, &pairs); check("light", light, &pairs); @@ -157,3 +159,24 @@ fn themes_define_the_same_vars() { ); } } + +#[test] +fn field_boundaries_pass_ui_contrast() { + // f0rge #90: a control boundary needs 3:1 against its own fill. + let css = css(); + let dark = block(&css, css.find(":root{").expect("dark root")); + let light = block( + &css, + css.find("@media (prefers-color-scheme:light)") + .expect("light block"), + ); + for (theme, block) in [("dark", dark), ("light", light)] { + let border = var(block, "--field-border"); + let fill = var(block, "--surface"); + let ratio = contrast(&border, &fill); + assert!( + ratio >= 3.0, + "{theme}: --field-border {border} on --surface {fill} = {ratio:.2}:1 (UI needs 3)" + ); + } +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..4ed018f --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,9 @@ +# Cloudflare Pages project config (f0rge #79). +# `npx wrangler pages deploy dist` reads this file and the functions/ dir. +name = "hu13" +pages_build_output_dir = "dist" +compatibility_date = "2026-01-01" + +[[r2_buckets]] +binding = "INBOX" +bucket_name = "hu13-inbox" -- 2.56.0