diff --git a/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/patch.diff b/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/patch.diffnew file mode 100644index 0000000..3c9f473--- /dev/null+++ b/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/patch.diff@@ -0,0 +1,1473 @@1+From 938c647cf274fd0d04067b33d004ff034963b1d2 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 19:49:57 +00004+Subject: [PATCH] review #5: harden Pages headers, .forge symlinks, links,5+ origin/static parity (#106-#117)6+7+---8+ .forge/wiki/deploy | 5 +9+ docs/cloudflare.md | 12 +-10+ functions/inbox/[kind].js | 25 +-11+ src/build.rs | 262 ++++++++++++++++++--12+ src/forge.rs | 77 ++++--13+ src/paths.rs | 61 ++++-14+ src/releases.rs | 36 ++-15+ src/serve.rs | 21 +-16+ src/wiki.rs | 15 +-17+ tests/pages_inbox.test.mjs | 21 +-18+ tests/review4_hardening.rs | 6 +-19+ tests/review5_hardening.rs | 477 +++++++++++++++++++++++++++++++++++++20+ 12 files changed, 958 insertions(+), 60 deletions(-)21+ create mode 100644 tests/review5_hardening.rs22+23+diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy24+index ac9b9d3..d13f21c 10064425+--- a/.forge/wiki/deploy26++++ b/.forge/wiki/deploy27+@@ -70,6 +70,11 @@ What happens inside (`entrypoint.sh`):28+ `/releases/<version>/**` download policy for Pages. On nginx, add the29+ equivalent `add_header Content-Disposition attachment` for `/raw/` and30+ release artifact directories — committed HTML/SVG must never render.31++- Pages clean-URL redirects: Pages answers `.html` paths with a 30832++ (`/issues/index.html` → `/issues/`, `/files/a.md.html` →33++ `/files/a.md`); the links still resolve, and Docker/nginx serve the34++ exact path. `_redirects` status 200 cannot override this. If exact URL35++ shape matters, use a proxy/Worker or accept the Pages form.36+ - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The37+ Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and38+ secret `INBOX_SALT` implement the same contract. See39+diff --git a/docs/cloudflare.md b/docs/cloudflare.md40+index b561c9e..7e6c420 10064441+--- a/docs/cloudflare.md42++++ b/docs/cloudflare.md43+@@ -4,6 +4,14 @@ The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On44+ Pages the inbox is implemented by a Pages Function backed by R2; a45+ static-only upload has no working inbox.46+ 47++Pages normalizes `.html` request paths with a 308 (for example48++`/issues/index.html` → `/issues/` and `/files/a.md.html` →49++`/files/a.md`). Every generated link still reaches the same page; the50++redirect is a Pages platform behavior, not a missing file. Docker/nginx51++serve the exact `.html` path with no redirect. If byte-identical URLs52++matter, put a proxy/Worker in front or accept the Pages canonical form;53++`_redirects` status 200 cannot override these clean-URL redirects.54++55+ ## 1. Build the site56+ 57+ ```sh58+@@ -94,4 +102,6 @@ it does not read R2. Copy objects down if you want the terminal reader.59+ - Committed `/raw/*` bytes and release artifacts under60+ `/releases/<version>/*` always download (`Content-Disposition:61+ attachment`) on both `serve` and Pages, so hostile HTML/SVG can never62+- execute in the inbox origin.63++ execute in the inbox origin. The Pages `_headers` rule uses one named64++ placeholder plus one splat (`/releases/:version/*`): Pages silently65++ skips rules with two splats.66+diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js67+index 1fa9037..3ef6121 10064468+--- a/functions/inbox/[kind].js69++++ b/functions/inbox/[kind].js70+@@ -258,13 +258,17 @@ async function saltedIpHash(ip, salt) {71+ // rule for /inbox/* is the durable control; this still stops a single72+ // hot isolate from storing unlimited submissions.73+ const buckets = new Map();74++const MAX_RATE_KEYS = 10000;75+ function rateAllowed(key, limit) {76+ const now = Date.now();77+- if (buckets.size > 10000) {78++ if (buckets.size >= MAX_RATE_KEYS) {79+ for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);80+ }81+ let b = buckets.get(key);82+ if (!b || b.reset <= now) {83++ // Fail closed for a new key when the isolate map is full; do not let84++ // source-IP rotation grow it without bound (f0rge #116).85++ if (!b && buckets.size >= MAX_RATE_KEYS) return false;86+ b = { count: 0, reset: now + 3600_000 };87+ buckets.set(key, b);88+ }89+@@ -273,9 +277,24 @@ function rateAllowed(key, limit) {90+ return true;91+ }92+ 93++// R2 list returns at most 1000 objects per call. Paginate until the94++// configured quota is counted or the listing ends; a truncated page95++// without a cursor fails closed (f0rge #116).96+ async function overQuota(bucket, limit) {97+- const listed = await bucket.list({ limit: Math.min(limit, 1000) });98+- return listed.objects.length >= limit;99++ if (!(limit > 0)) return true;100++ let count = 0;101++ let cursor;102++ for (;;) {103++ const page = await bucket.list({104++ limit: Math.min(limit - count, 1000),105++ cursor,106++ });107++ count += page.objects.length;108++ if (count >= limit) return true;109++ if (!page.truncated) return false;110++ if (!page.cursor) return true;111++ cursor = page.cursor;112++ }113+ }114+ 115+ function nowRfc3339() {116+diff --git a/src/build.rs b/src/build.rs117+index 4274e88..a572829 100644118+--- a/src/build.rs119++++ b/src/build.rs120+@@ -191,14 +191,184 @@ fn safe_url(url: &str) -> bool {121+ matches!(scheme, "http" | "https" | "mailto" | "tel")122+ }123+ 124+-/// Render markdown to HTML, escaping raw HTML.125+-///126+-/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched127+-/// (R1 finding), so map them through [`esc`] first. GFM tables, task128+-/// lists and strikethrough are enabled (f0rge #71); link/image129+-/// destinations are scheme-filtered (f0rge #62).130+-#[must_use]131+-pub fn markdown_to_html(md: &str) -> String {132++/// Context for relative markdown destinations: a repo file page uses133++/// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses134++/// `/wiki/...` + raw/assets for non-page files.135++#[derive(Clone, Copy)]136++enum LinkBase<'a> {137++ Repo(&'a str),138++ Wiki(&'a str),139++}140++141++fn base_path(base: LinkBase<'_>) -> &str {142++ match base {143++ LinkBase::Repo(p) | LinkBase::Wiki(p) => p,144++ }145++}146++147++/// Lexically resolve a relative link against a repo- or wiki-relative148++/// base directory. Returns `None` when it climbs above the root or has149++/// unsafe/absolute components.150++fn rel_join(base: &str, path: &str) -> Option<String> {151++ if path.starts_with('/') || path.contains(['\\', '\0']) {152++ return None;153++ }154++ let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();155++ for seg in path.split('/') {156++ match seg {157++ "" | "." => {}158++ ".." => {159++ parts.pop()?;160++ }161++ other => parts.push(other),162++ }163++ }164++ if parts.is_empty() {165++ return None;166++ }167++ Some(parts.join("/"))168++}169++170++/// Extensions safe to display inline from `assets/`: raster/static171++/// images whose bytes cannot execute as a document. SVG and HTML stay172++/// downloads under `raw/`.173++fn is_inline_image(path: &str) -> bool {174++ let base = path.rsplit('/').next().unwrap_or(path);175++ let Some((stem, ext)) = base.rsplit_once('.') else {176++ return false;177++ };178++ !stem.is_empty()179++ && matches!(180++ ext.to_ascii_lowercase().as_str(),181++ "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"182++ )183++}184++185++/// Rewrite one relative markdown destination to the generated route.186++/// External URLs, fragments, absolute paths and anything that cannot be187++/// resolved stay as-is. Query/fragment suffixes are preserved.188++fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option<String> {189++ let d = dest.trim();190++ if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {191++ return None;192++ }193++ // A real scheme (`http:`, `mailto:`, ...) is not a relative file.194++ if let Some(colon) = d.find(':') {195++ let scheme = &d[..colon];196++ let mut chars = scheme.chars();197++ if chars.next().is_some_and(|c| c.is_ascii_alphabetic())198++ && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))199++ {200++ return None;201++ }202++ }203++ let cut = d.find(['?', '#']).unwrap_or(d.len());204++ let (path, suffix) = d.split_at(cut);205++ let target = rel_join(base_path(base), path)?;206++ let target = target.trim_end_matches('/');207++ if target.is_empty() {208++ return None;209++ }210++ let enc = encode_path(target);211++ let asset = match base {212++ LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),213++ LinkBase::Repo(_) => format!("/assets/{enc}"),214++ };215++ let route = if is_inline_image(target) {216++ asset217++ } else if image {218++ format!("/raw/{enc}")219++ } else {220++ match base {221++ LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),222++ LinkBase::Repo(_) => {223++ let name = target.rsplit('/').next().unwrap_or(target);224++ if is_markdown(target) || name.eq_ignore_ascii_case("readme") {225++ format!("/files/{enc}.html")226++ } else {227++ format!("/raw/{enc}")228++ }229++ }230++ }231++ };232++ Some(format!("{route}{suffix}"))233++}234++235++/// Strip simple inline/block tags for a heading slug. Entity decoding236++/// runs afterwards so `&` becomes a word separator, not `amp`.237++fn heading_text(inner: &str) -> String {238++ let mut out = String::with_capacity(inner.len());239++ let mut in_tag = false;240++ for c in inner.chars() {241++ match c {242++ '<' => in_tag = true,243++ '>' => in_tag = false,244++ _ if !in_tag => out.push(c),245++ _ => {}246++ }247++ }248++ decode_entities(&out)249++}250++251++fn slugify(text: &str) -> String {252++ let mut out = String::new();253++ let mut dash = false;254++ for c in text.chars().flat_map(char::to_lowercase) {255++ if c.is_alphanumeric() {256++ out.push(c);257++ dash = false;258++ } else if !out.is_empty() && !dash {259++ out.push('-');260++ dash = true;261++ }262++ }263++ while out.ends_with('-') {264++ out.pop();265++ }266++ if out.is_empty() {267++ "section".to_string()268++ } else {269++ out270++ }271++}272++273++fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {274++ let b = line.as_bytes();275++ if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'276++ {277++ return None;278++ }279++ let level = usize::from(b[2] - b'0');280++ let close = format!("</h{level}>");281++ let end = line.find(&close)?;282++ Some((level, &line[4..end], &line[end + close.len()..]))283++}284++285++/// Add deterministic heading ids so `#section` links work. Input is the286++/// renderer's own output, so headings are one per line and contain only287++/// generated tags; code-block lines are entity-escaped and never match.288++fn add_heading_ids(html: &str) -> String {289++ let mut out = String::with_capacity(html.len() + 64);290++ let mut seen: BTreeMap<String, usize> = BTreeMap::new();291++ for line in html.split_inclusive('\n') {292++ if let Some((level, inner, tail)) = heading_parts(line) {293++ let base = slugify(&heading_text(inner));294++ let count = seen.entry(base.clone()).or_insert(0);295++ *count += 1;296++ let id = if *count == 1 {297++ base298++ } else {299++ format!("{base}-{}", *count)300++ };301++ out.push_str(&format!("<h{level} id=\"{id}\">{inner}</h{level}>{tail}"));302++ } else {303++ out.push_str(line);304++ }305++ }306++ out307++}308++309++fn render_markdown(md: &str, base: Option<LinkBase<'_>>) -> String {310+ use pulldown_cmark::{html, Event, Options, Parser, Tag};311+ let options = Options::ENABLE_TABLES312+ | Options::ENABLE_TASKLISTS313+@@ -215,7 +385,11 @@ pub fn markdown_to_html(md: &str) -> String {314+ id,315+ }) => {316+ let dest = if safe_url(&dest_url) {317+- decode_entities(&dest_url)318++ let decoded = decode_entities(&dest_url);319++ match base.and_then(|b| rewrite_dest(&decoded, b, false)) {320++ Some(rewritten) => rewritten,321++ None => decoded,322++ }323+ } else {324+ "#".to_string()325+ };326+@@ -233,7 +407,11 @@ pub fn markdown_to_html(md: &str) -> String {327+ id,328+ }) => {329+ let dest = if safe_url(&dest_url) {330+- decode_entities(&dest_url)331++ let decoded = decode_entities(&dest_url);332++ match base.and_then(|b| rewrite_dest(&decoded, b, true)) {333++ Some(rewritten) => rewritten,334++ None => decoded,335++ }336+ } else {337+ "#".to_string()338+ };339+@@ -249,7 +427,31 @@ pub fn markdown_to_html(md: &str) -> String {340+ let mut out = String::from("<div class=\"markdown\">\n");341+ html::push_html(&mut out, mapped);342+ out.push_str("</div>\n");343+- out344++ add_heading_ids(&out)345++}346++347++/// Render markdown to HTML, escaping raw HTML.348++///349++/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched350++/// (R1 finding), so map them through [`esc`] first. GFM tables, task351++/// lists and strikethrough are enabled (f0rge #71); link/image352++/// destinations are scheme-filtered (f0rge #62). Headings get stable353++/// ids so `#section` links resolve (f0rge #112).354++#[must_use]355++pub fn markdown_to_html(md: &str) -> String {356++ render_markdown(md, None)357++}358++359++/// Render markdown from a repo file page or wiki page, rewriting360++/// relative links/images to generated routes (f0rge #112).361++#[must_use]362++pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {363++ let base = if wiki {364++ LinkBase::Wiki(base)365++ } else {366++ LinkBase::Repo(base)367++ };368++ render_markdown(md, Some(base))369+ }370+ 371+ /// Resolve the clone URL to advertise.372+@@ -547,6 +749,12 @@ pub fn now_unix() -> i64 {373+ #[must_use]374+ pub fn parse_ts_secs(s: &str) -> Option<i64> {375+ let s = s.trim();376++ // Timestamps are ASCII by definition. Reject other text before any377++ // byte-index slicing: a 200-char multibyte `created` used to panic378++ // on a non-char-boundary (f0rge #108).379++ if !s.is_ascii() {380++ return None;381++ }382+ // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's383+ // `2026-10-27 14:08:48 +0000` / `+00:00` forms.384+ let (date, rest) = match s.split_once('T') {385+@@ -1154,7 +1362,10 @@ pub const HEADERS_FILE: &str = "\386+ X-Content-Type-Options: nosniff387+ # Artifacts are build outputs copied from .forge/releases/out/<ver>/;388+ # never let an artifact .html/.svg execute in the site origin (f0rge #97).389+-/releases/*/*390++# Cloudflare Pages allows one splat per rule. A named `:version`391++# placeholder plus one splat covers every artifact depth (f0rge #106).392++# A two-splat rule is silently skipped by Pages and leaves artifacts active.393++/releases/:version/*394+ Content-Disposition: attachment395+ X-Content-Type-Options: nosniff396+ /*397+@@ -1486,6 +1697,17 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu398+ std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;399+ blobs.insert(f.clone(), blob.bytes);400+ }401++ // Raster images mentioned by markdown need an inline route; `/raw`402++ // intentionally downloads everything. Copy only non-executable403++ // image formats under `/assets` (f0rge #112). SVG/HTML stay raw.404++ if is_inline_image(f) {405++ let adest = out.join("assets").join(f);406++ if let Some(parent) = adest.parent() {407++ std::fs::create_dir_all(parent)408++ .map_err(|e| format!("hu13: create assets parent: {e}"))?;409++ }410++ std::fs::copy(&dest, &adest).map_err(|e| format!("hu13: write asset {f}: {e}"))?;411++ }412+ }413+ 414+ // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file415+@@ -1591,7 +1813,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu416+ bytes.len() as u64,417+ Some(display.lines().count()),418+ ));419+- body.push_str(&markdown_to_html(&display));420++ let base = f.rsplit_once('/').map(|(d, _)| d).unwrap_or("");421++ body.push_str(&markdown_to_html_in(&display, base, false));422+ body.push_str(&format!(423+ "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",424+ href_esc(f)425+@@ -1733,7 +1956,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu426+ esc(path)427+ ));428+ if is_markdown(path) || path.to_lowercase().contains("readme") {429+- body.push_str(&markdown_to_html(&blob.display));430++ let (base, wiki) = if let Some(rest) = path.strip_prefix(".forge/wiki/") {431++ (rest.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), true)432++ } else {433++ (path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), false)434++ };435++ body.push_str(&markdown_to_html_in(&blob.display, base, wiki));436+ } else {437+ body.push_str(&render_text_pre(&blob.display, path));438+ }439+@@ -2070,7 +2298,7 @@ mod tests {440+ let (_repo, out) = build_fixture();441+ let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");442+ assert!(443+- html.contains("<h1>Guide</h1>"),444++ html.contains("<h1 id=\"guide\">Guide</h1>"),445+ "markdown rendered, got: {html}"446+ );447+ assert!(html.contains("/raw/docs/guide.md"), "raw link kept");448+@@ -2243,7 +2471,7 @@ mod tests {449+ #[test]450+ fn markdown_escapes_raw_html() {451+ let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");452+- assert!(html.contains("<h1>hi</h1>"), "heading renders");453++ assert!(html.contains("<h1 id=\"hi\">hi</h1>"), "heading renders");454+ assert!(!html.contains("<script>"), "no raw passthrough");455+ assert!(html.contains("<script>"), "escaped instead");456+ assert!(457+@@ -3035,6 +3263,6 @@ mod tests {458+ // Agents guide renders its markdown h1; spacing comes from the459+ // shared `main>h1:first-child` gap-var rule (CSS assert in layout).460+ let agents = read("agents.html");461+- assert!(agents.contains("<h1>"), "agents h1");462++ assert!(agents.contains("<h1"), "agents h1");463+ }464+ }465+diff --git a/src/forge.rs b/src/forge.rs466+index ecdbcba..d866287 100644467+--- a/src/forge.rs468++++ b/src/forge.rs469+@@ -22,7 +22,7 @@ use std::path::Path;470+ 471+ use serde::Deserialize;472+ 473+-use crate::build::markdown_to_html;474++use crate::build::markdown_to_html_in;475+ use crate::layout::{diff_html, esc, page_site_with_provenance};476+ 477+ /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).478+@@ -89,6 +89,35 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {479+ }480+ }481+ 482++/// Like [`read_text_capped`], but keeps arbitrary bytes (CI logs may be483++/// lossy UTF-8). Symlinks and oversized files are skipped, so `.forge`484++/// cannot make the build read an unbounded or outside file (f0rge #107).485++fn read_bytes_capped(path: &Path, label: &str) -> Option<Vec<u8>> {486++ match std::fs::symlink_metadata(path) {487++ Ok(m) if m.file_type().is_symlink() => {488++ eprintln!("hu13: forge: skipping {label}: symlink");489++ return None;490++ }491++ _ => {}492++ }493++ let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);494++ if len > FORGE_MAX_BYTES {495++ eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");496++ return None;497++ }498++ match std::fs::read(path) {499++ Ok(bytes) if bytes.len() as u64 <= FORGE_MAX_BYTES => Some(bytes),500++ Ok(_) => {501++ eprintln!("hu13: forge: skipping {label}: over 256 KiB");502++ None503++ }504++ Err(e) => {505++ eprintln!("hu13: forge: skipping {label}: cannot read: {e}");506++ None507++ }508++ }509++}510++511+ /// Parse YAML frontmatter; on error warn (naming file via `label` plus the512+ /// serde error, which names the field) and return `None` (skip the file).513+ fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>514+@@ -120,8 +149,13 @@ fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {515+ continue;516+ }517+ };518+- let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);519+- if is_dir != only_dirs {520++ let meta = match std::fs::symlink_metadata(ent.path()) {521++ Ok(m) => m,522++ Err(_) => continue,523++ };524++ // Never descend or read through a symlink: `.forge` entries are525++ // repo-controlled and must not escape the checkout (f0rge #107).526++ if meta.file_type().is_symlink() || meta.is_dir() != only_dirs {527+ continue;528+ }529+ names.push(ent.file_name().to_string_lossy().into_owned());530+@@ -592,6 +626,10 @@ fn load_issues(root: &Path) -> Vec<Issue> {531+ eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);532+ continue;533+ }534++ if crate::paths::is_reserved_index(&fm.id) {535++ eprintln!("hu13: forge: skipping {label}: reserved id index");536++ continue;537++ }538+ let comments = load_comments(539+ &dir.join(&uuid).join("comments"),540+ &format!("issues/{uuid}/comments"),541+@@ -637,9 +675,13 @@ fn load_prs(root: &Path) -> Vec<Pr> {542+ eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);543+ continue;544+ }545+- let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))546+- .ok()547+- .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());548++ if crate::paths::is_reserved_index(&fm.id) {549++ eprintln!("hu13: forge: skipping {label}: reserved id index");550++ continue;551++ }552++ // Symlink-safe and bounded: a hostile patch.diff used to be read553++ // whole, even through a symlink (f0rge #107).554++ let patch = read_text_capped(&dir.join(&uuid).join("patch.diff"), "patch.diff");555+ let comments = load_comments(556+ &dir.join(&uuid).join("comments"),557+ &format!("prs/{uuid}/comments"),558+@@ -839,8 +881,12 @@ fn load_ci(root: &Path) -> CiInfo {559+ if !name.ends_with(".log") {560+ continue;561+ }562+- let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))563+- .unwrap_or_default();564++ let label = format!("ci/runs/{ts}/steps/{name}");565++ let bytes = read_bytes_capped(566++ &dir.join("runs").join(&ts).join("steps").join(&name),567++ &label,568++ )569++ .unwrap_or_default();570+ let text = String::from_utf8_lossy(&bytes);571+ logs.push((name, truncate_log(&redact_paths(&text, repo_root))));572+ }573+@@ -874,7 +920,10 @@ fn load_ci(root: &Path) -> CiInfo {574+ /// `.forge/` yields empty sections.575+ #[must_use]576+ pub fn load(repo: &Path) -> ForgeData {577+- let root = repo.join(".forge");578++ // Resolve `.forge` and require it to stay inside the repo. A symlinked579++ // `.forge` used to publish host issues/wiki/releases (f0rge #107).580++ let root = crate::paths::repo_subdir(repo, ".forge")581++ .unwrap_or_else(|| repo.join(".hu13-missing-forge"));582+ ForgeData {583+ issues: load_issues(&root),584+ prs: load_prs(&root),585+@@ -971,7 +1020,7 @@ fn render_comment(c: &Comment, now: i64) -> String {586+ esc(author),587+ time_ago(&c.created, now),588+ ));589+- out.push_str(&markdown_to_html(&c.body));590++ out.push_str(&markdown_to_html_in(&c.body, "", false));591+ out.push_str("</li>\n");592+ out593+ }594+@@ -1022,7 +1071,7 @@ fn render_issue_page(595+ esc(&issue.id)596+ ));597+ body.push_str(&details);598+- body.push_str(&markdown_to_html(&issue.body));599++ body.push_str(&markdown_to_html_in(&issue.body, "", false));600+ body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));601+ if issue.comments.is_empty() {602+ body.push_str("<p class=\"muted\">No comments yet.</p>\n");603+@@ -1096,7 +1145,7 @@ fn render_pr_page(604+ esc(&pr.id)605+ ));606+ body.push_str(&details);607+- body.push_str(&markdown_to_html(&pr.body));608++ body.push_str(&markdown_to_html_in(&pr.body, "", false));609+ body.push_str("<h2>Patch</h2>\n");610+ match &pr.patch {611+ Some(patch) if !patch.is_empty() => {612+@@ -1131,7 +1180,7 @@ fn render_pr_page(613+ esc(author),614+ time_ago(&r.created, now),615+ esc(state),616+- markdown_to_html(&r.body),617++ markdown_to_html_in(&r.body, "", false),618+ ));619+ }620+ body.push_str("</ul>\n");621+@@ -1312,7 +1361,7 @@ fn render_tasks_index(622+ "<p class=\"muted\">assignee: <code>{}</code></p>\n",623+ esc(a)624+ )),625+- body_md = markdown_to_html(&t.body),626++ body_md = markdown_to_html_in(&t.body, "", false),627+ ));628+ }629+ body.push_str("</ul>\n");630+diff --git a/src/paths.rs b/src/paths.rs631+index 437caaa..049b015 100644632+--- a/src/paths.rs633++++ b/src/paths.rs634+@@ -7,7 +7,11 @@635+ //!636+ //! Rules: no absolute paths, no `\` or NUL, no empty/`.`/`..` segments.637+ //! The checks are lexical on purpose — they run before anything is638+-//! created, so there is nothing to canonicalize yet.639++//! created, so there is nothing to canonicalize yet. Filesystem readers640++//! additionally use [`repo_subdir`] so a symlinked `.forge` cannot point641++//! outside the checkout (f0rge #107).642++643++use std::path::{Path, PathBuf};644+ 645+ /// `true` when `segment` is a single safe path component.646+ #[must_use]647+@@ -24,6 +28,29 @@ pub fn is_safe_relative(rel: &str) -> bool {648+ && rel.split('/').all(is_safe_segment)649+ }650+ 651++/// Resolve `repo/rel` through symlinks and require the result to stay652++/// inside the resolved repo root. Returns `None` for missing paths,653++/// symlink escapes, or the repo root itself. Used for untrusted654++/// `.forge` trees (f0rge #107).655++#[must_use]656++pub fn repo_subdir(repo: &Path, rel: &str) -> Option<PathBuf> {657++ let root = std::fs::canonicalize(repo).ok()?;658++ let target = std::fs::canonicalize(root.join(rel)).ok()?;659++ if target == root || !target.starts_with(&root) {660++ return None;661++ }662++ Some(target)663++}664++665++/// `true` for names that would overwrite a generated section index666++/// (`releases/index.html`, `issues/index.html`, `pulls/index.html`,667++/// `wiki/index.html`). Case-insensitive so a case-insensitive host668++/// cannot collide either (f0rge #110).669++#[must_use]670++pub fn is_reserved_index(name: &str) -> bool {671++ name.eq_ignore_ascii_case("index")672++}673++674+ /// `true` when `id` is safe both as a path segment and as a displayed675+ /// forge id: ASCII alphanumerics, `-`, `_` only.676+ #[must_use]677+@@ -71,4 +98,36 @@ mod tests {678+ assert!(!is_safe_id(bad), "{bad:?} must be rejected");679+ }680+ }681++682++ #[test]683++ fn reserved_index_names() {684++ for name in ["index", "INDEX", "Index"] {685++ assert!(is_reserved_index(name));686++ }687++ for name in ["index.html", "indexes", "my-index", ""] {688++ assert!(!is_reserved_index(name));689++ }690++ }691++692++ #[test]693++ fn subdir_stays_inside_repo() {694++ let dir = std::env::temp_dir().join(format!(695++ "hu13-paths-{}-{}",696++ std::process::id(),697++ std::time::SystemTime::now()698++ .duration_since(std::time::UNIX_EPOCH)699++ .map(|d| d.as_nanos())700++ .unwrap_or(0)701++ ));702++ std::fs::create_dir_all(dir.join("inside")).unwrap();703++ std::fs::write(dir.join("inside/x"), "x").unwrap();704++ assert!(repo_subdir(&dir, "inside").is_some());705++ assert!(repo_subdir(&dir, "missing").is_none());706++ #[cfg(unix)]707++ {708++ std::os::unix::fs::symlink("/etc", dir.join("escape")).unwrap();709++ assert!(repo_subdir(&dir, "escape").is_none());710++ }711++ let _ = std::fs::remove_dir_all(&dir);712++ }713+ }714+diff --git a/src/releases.rs b/src/releases.rs715+index 75acf0c..c9b9d28 100644716+--- a/src/releases.rs717++++ b/src/releases.rs718+@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};719+ 720+ use serde::Deserialize;721+ 722+-use crate::build::{href_esc, markdown_to_html};723++use crate::build::{href_esc, markdown_to_html_in};724+ use crate::layout::{esc, page_site_with_provenance};725+ 726+ /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).727+@@ -125,6 +125,11 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {728+ /// Collect every file under `dir` as `(relative /-separated path, size)`,729+ /// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.730+ fn collect_artifacts(dir: &Path) -> Vec<Artifact> {731++ // The version directory itself may be a symlink; never list outside.732++ match std::fs::symlink_metadata(dir) {733++ Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}734++ _ => return Vec::new(),735++ }736+ let mut out = Vec::new();737+ let mut stack: Vec<PathBuf> = vec![PathBuf::new()];738+ while let Some(rel) = stack.pop() {739+@@ -179,6 +184,10 @@ fn collect_artifacts(dir: &Path) -> Vec<Artifact> {740+ 741+ /// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).742+ fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {743++ match std::fs::symlink_metadata(src_dir) {744++ Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}745++ _ => return Ok(()),746++ }747+ let mut stack: Vec<PathBuf> = vec![PathBuf::new()];748+ while let Some(rel) = stack.pop() {749+ let src = src_dir.join(&rel);750+@@ -226,7 +235,9 @@ fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {751+ /// an empty vec. Order is `(created, version)`.752+ #[must_use]753+ pub fn load(repo: &Path) -> Vec<Release> {754+- let root = repo.join(".forge").join("releases");755++ let Some(root) = crate::paths::repo_subdir(repo, ".forge/releases") else {756++ return Vec::new();757++ };758+ let rd = match std::fs::read_dir(&root) {759+ Ok(rd) => rd,760+ Err(_) => return Vec::new(),761+@@ -280,6 +291,10 @@ pub fn load(repo: &Path) -> Vec<Release> {762+ );763+ continue;764+ }765++ if crate::paths::is_reserved_index(&fm.version) {766++ eprintln!("hu13: releases: skipping {label}: reserved version index");767++ continue;768++ }769+ // Artifacts: present only when `out/<version>/` exists at build.770+ // Absent -> notes-only, no warning.771+ let out_dir = root.join("out").join(&fm.version);772+@@ -366,7 +381,7 @@ fn render_release_page(773+ esc(sha)774+ ));775+ }776+- body.push_str(&markdown_to_html(&rel.body));777++ body.push_str(&markdown_to_html_in(&rel.body, "", false));778+ if rel.artifacts.is_empty() {779+ body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");780+ } else {781+@@ -428,7 +443,7 @@ pub fn render_loaded(782+ short_sha: &str,783+ description: Option<&str>,784+ ) -> Result<(), String> {785+- let root = repo.join(".forge").join("releases");786++ let root = crate::paths::repo_subdir(repo, ".forge/releases");787+ let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");788+ if releases.is_empty() {789+ body.push_str("<p class=\"muted\">No releases yet.</p>\n");790+@@ -472,11 +487,14 @@ pub fn render_loaded(791+ rel.version792+ ));793+ }794+- // Copy the whole out dir when present (no size cap).795+- let src_dir = root.join("out").join(&rel.version);796+- if src_dir.is_dir() {797+- let dest_dir = out.join("releases").join(&rel.version);798+- copy_tree(&src_dir, &dest_dir)?;799++ // Copy the whole out dir when present (no size cap). The root is800++ // resolved once; copy_tree still refuses a symlinked version dir.801++ if let Some(root) = root.as_ref() {802++ let src_dir = root.join("out").join(&rel.version);803++ if src_dir.is_dir() {804++ let dest_dir = out.join("releases").join(&rel.version);805++ copy_tree(&src_dir, &dest_dir)?;806++ }807+ }808+ let html = render_release_page(rel, site_name, nav, branch, short_sha, description);809+ let page_dest = out.join("releases").join(format!("{}.html", rel.version));810+diff --git a/src/serve.rs b/src/serve.rs811+index 9edb446..89bf867 100644812+--- a/src/serve.rs813++++ b/src/serve.rs814+@@ -110,8 +110,12 @@ struct Shared {815+ /// startup. Returns `None` when no bundle exists or git fails (serve816+ /// continues static-only; `/repo.bundle` still downloads via GET).817+ fn prepare_git_dir(www: &Path) -> Option<PathBuf> {818+- let bundle = www.join("repo.bundle");819+- if !bundle.is_file() {820++ // Static serving already rejects an outside symlink; smart HTTP must821++ // use the same resolved-path check (f0rge #117). A symlink pointing822++ // inside www is allowed, but a symlink to a private bundle is not.823++ let www_root = resolve_existing(www);824++ let bundle = resolve_existing(&www.join("repo.bundle"));825++ if bundle == www_root || !bundle.starts_with(&www_root) || !bundle.is_file() {826+ return None;827+ }828+ let dir = std::env::temp_dir().join(format!(829+@@ -428,7 +432,7 @@ fn respond(830+ body: &[u8],831+ ) {832+ let mut out = format!(833+- "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",834++ "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",835+ reason(code),836+ body.len()837+ );838+@@ -492,6 +496,8 @@ fn content_type_for(name: &str) -> &'static str {839+ "image/webp"840+ } else if lower.ends_with(".ico") {841+ "image/x-icon"842++ } else if lower.ends_with(".avif") {843++ "image/avif"844+ } else if lower.ends_with(".webmanifest") {845+ "application/manifest+json"846+ } else if lower.ends_with(".json") {847+@@ -888,7 +894,7 @@ fn respond_file_head(848+ len: u64,849+ ) {850+ let mut out = format!(851+- "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",852++ "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",853+ reason(code)854+ );855+ for (k, v) in extra {856+@@ -910,7 +916,12 @@ fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {857+ static_error(stream, 404);858+ return;859+ }860+- let dest = shared.www.join("404.html");861++ let www = resolve_existing(&shared.www);862++ let dest = resolve_existing(&shared.www.join("404.html"));863++ if dest == www || !dest.starts_with(&www) {864++ static_error(stream, 404);865++ return;866++ }867+ let Ok(meta) = std::fs::metadata(&dest) else {868+ static_error(stream, 404);869+ return;870+diff --git a/src/wiki.rs b/src/wiki.rs871+index 25bfc48..8465473 100644872+--- a/src/wiki.rs873++++ b/src/wiki.rs874+@@ -14,7 +14,7 @@875+ use std::collections::BTreeMap;876+ use std::path::Path;877+ 878+-use crate::build::{href_esc, markdown_to_html};879++use crate::build::{href_esc, markdown_to_html_in};880+ use crate::layout::{esc, page_site_with_provenance};881+ 882+ /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).883+@@ -75,7 +75,8 @@ pub fn page_title(body: &str, filename: &str) -> String {884+ /// [`render`] would emit for skipped entries.885+ #[must_use]886+ pub fn page_count(repo: &Path) -> usize {887+- let root = repo.join(".forge").join("wiki");888++ let root = crate::paths::repo_subdir(repo, ".forge/wiki")889++ .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));890+ if !root.exists() {891+ return 0;892+ }893+@@ -128,7 +129,7 @@ pub fn page_count(repo: &Path) -> usize {894+ };895+ if meta.is_dir() {896+ stack.push(rel);897+- } else if meta.is_file() {898++ } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {899+ rel_files.push(rel);900+ }901+ }902+@@ -169,7 +170,8 @@ pub fn render(903+ short_sha: &str,904+ description: Option<&str>,905+ ) -> Result<Vec<WikiPage>, String> {906+- let root = repo.join(".forge").join("wiki");907++ let root = crate::paths::repo_subdir(repo, ".forge/wiki")908++ .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));909+ let mut rel_files: Vec<String> = Vec::new();910+ if root.exists() {911+ // Iterative walk over `/`-separated relative dirs. Symlinked dirs912+@@ -226,7 +228,7 @@ pub fn render(913+ };914+ if meta.is_dir() {915+ stack.push(rel);916+- } else if meta.is_file() {917++ } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {918+ rel_files.push(rel);919+ }920+ }921+@@ -317,7 +319,8 @@ pub fn render(922+ esc(&p.title),923+ esc(&p.path),924+ );925+- body.push_str(&markdown_to_html(&text));926++ let base = p.path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");927++ body.push_str(&markdown_to_html_in(&text, base, true));928+ let html = page_site_with_provenance(929+ &format!("{} — wiki — {site_name}", p.title),930+ nav,931+diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs932+index a825c71..1696bae 100644933+--- a/tests/pages_inbox.test.mjs934++++ b/tests/pages_inbox.test.mjs935+@@ -10,10 +10,16 @@ class FakeBucket {936+ constructor() {937+ this.objects = new Map();938+ }939+- async list({ limit = 1000 } = {}) {940++ async list({ limit = 1000, cursor } = {}) {941++ const keys = [...this.objects.keys()];942++ const start = cursor === undefined ? 0 : Number(cursor);943++ const objects = keys.slice(start, start + limit).map((key) => ({ key }));944++ const next = start + objects.length;945++ const truncated = next < keys.length;946+ return {947+- objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),948+- truncated: this.objects.size > limit,949++ objects,950++ truncated,951++ cursor: truncated ? String(next) : undefined,952+ };953+ }954+ async put(key, value) {955+@@ -159,6 +165,15 @@ test("quota is enforced before storing", async () => {956+ assert.equal(e.INBOX.objects.size, 1);957+ });958+ 959++test("quota above 1000 paginates R2 listings", async () => {960++ const bucket = new FakeBucket();961++ for (let i = 0; i < 1500; i++) bucket.objects.set(`old-${i}.json`, "x");962++ const e = env({ INBOX: bucket, INBOX_QUOTA: "1500" });963++ const { res } = await post("issue", validIssue, {}, e);964++ assert.equal(res.status, 503);965++ assert.equal(bucket.objects.size, 1500, "full inbox did not store");966++});967++968+ test("missing R2 binding or salt fails closed", async () => {969+ const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });970+ assert.equal(noBucket.res.status, 503);971+diff --git a/tests/review4_hardening.rs b/tests/review4_hardening.rs972+index 961f0bc..371bdc6 100644973+--- a/tests/review4_hardening.rs974++++ b/tests/review4_hardening.rs975+@@ -222,7 +222,11 @@ fn review4_build_and_release_hardening() {976+ );977+ 978+ let headers = read(&out.join("_headers"));979+- assert!(headers.contains("/releases/*/*"), "{headers}");980++ assert!(headers.contains("/releases/:version/*"), "{headers}");981++ assert!(982++ !headers.contains("/releases/*/*"),983++ "two splats are invalid on Pages: {headers}"984++ );985+ assert!(986+ headers.contains("Content-Disposition: attachment"),987+ "{headers}"988+diff --git a/tests/review5_hardening.rs b/tests/review5_hardening.rs989+new file mode 100644990+index 0000000..9756eaa991+--- /dev/null992++++ b/tests/review5_hardening.rs993+@@ -0,0 +1,477 @@994++//! Review #5 hardening (f0rge #106–#117): one-splat Pages artifact995++//! headers, symlink-safe `.forge` reads, multibyte timestamps, reserved996++//! indexes, context-aware markdown links/images/anchors, and `serve`997++//! header/404/bundle parity.998++999++use std::io::{BufRead, BufReader, Read, Write};1000++use std::net::TcpStream;1001++use std::path::{Path, PathBuf};1002++use std::process::{Child, Command, Stdio};1003++use std::sync::atomic::{AtomicU64, Ordering};1004++use std::time::Duration;1005++1006++static SEQ: AtomicU64 = AtomicU64::new(0);1007++1008++fn fresh(prefix: &str) -> PathBuf {1009++ let dir = std::env::temp_dir().join(format!(1010++ "hu13-review5-{prefix}-{}-{}",1011++ std::process::id(),1012++ std::time::SystemTime::now()1013++ .duration_since(std::time::UNIX_EPOCH)1014++ .map(|d| d.as_nanos())1015++ .unwrap_or(0)1016++ .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))1017++ ));1018++ std::fs::create_dir_all(&dir).unwrap();1019++ dir1020++}1021++1022++fn git(repo: &Path, args: &[&str]) {1023++ let st = Command::new("git")1024++ .arg("-C")1025++ .arg(repo)1026++ .args(args)1027++ .status()1028++ .expect("spawn git");1029++ assert!(st.success(), "git {args:?} failed");1030++}1031++1032++fn write(repo: &Path, rel: &str, body: &str) {1033++ let dest = repo.join(rel);1034++ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();1035++ std::fs::write(dest, body).unwrap();1036++}1037++1038++fn init_repo() -> PathBuf {1039++ let repo = fresh("repo");1040++ git(&repo, &["init", "-b", "main"]);1041++ git(&repo, &["config", "user.name", "hu13-test"]);1042++ git(&repo, &["config", "user.email", "[email protected]"]);1043++ git(&repo, &["config", "commit.gpgsign", "false"]);1044++ repo1045++}1046++1047++fn commit(repo: &Path, msg: &str) {1048++ git(repo, &["add", "-A"]);1049++ git(repo, &["commit", "-qm", msg]);1050++}1051++1052++fn build(repo: &Path, out: &Path) -> std::process::Output {1053++ Command::new(env!("CARGO_BIN_EXE_hu13"))1054++ .arg("build")1055++ .arg(repo)1056++ .arg("-o")1057++ .arg(out)1058++ .output()1059++ .expect("spawn hu13 build")1060++}1061++1062++fn read(path: &Path) -> String {1063++ std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))1064++}1065++1066++struct Server {1067++ child: Child,1068++ addr: String,1069++}1070++1071++impl Drop for Server {1072++ fn drop(&mut self) {1073++ self.child.kill().ok();1074++ self.child.wait().ok();1075++ }1076++}1077++1078++fn start(www: &Path, inbox: &Path) -> Server {1079++ let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));1080++ cmd.arg("serve")1081++ .arg("--www")1082++ .arg(www)1083++ .arg("--inbox-dir")1084++ .arg(inbox)1085++ .arg("--listen")1086++ .arg("127.0.0.1:0")1087++ .stdout(Stdio::piped())1088++ .stderr(Stdio::null());1089++ let mut child = cmd.spawn().expect("spawn serve");1090++ let stdout = child.stdout.take().expect("stdout");1091++ let mut addr = String::new();1092++ for line in BufReader::new(stdout).lines().take(50).flatten() {1093++ if let Some(rest) = line.split("listening on ").nth(1) {1094++ addr = rest.trim().to_string();1095++ break;1096++ }1097++ }1098++ assert!(!addr.is_empty(), "serve printed no listening line");1099++ Server { child, addr }1100++}1101++1102++fn get(addr: &str, path: &str) -> String {1103++ let mut s = TcpStream::connect(addr).expect("connect");1104++ s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();1105++ write!(1106++ s,1107++ "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"1108++ )1109++ .unwrap();1110++ let mut out = Vec::new();1111++ s.read_to_end(&mut out).unwrap();1112++ String::from_utf8_lossy(&out).into_owned()1113++}1114++1115++#[test]1116++fn pages_headers_use_one_splat_for_artifacts() {1117++ let repo = init_repo();1118++ write(&repo, "README.md", "# headers\n");1119++ commit(&repo, "init");1120++ let out = fresh("headers-out");1121++ let res = build(&repo, &out);1122++ assert!(1123++ res.status.success(),1124++ "{}",1125++ String::from_utf8_lossy(&res.stderr)1126++ );1127++ let headers = read(&out.join("_headers"));1128++ assert!(headers.contains("/releases/:version/*"), "{headers}");1129++ assert!(!headers.contains("/releases/*/*"), "invalid two-splat rule");1130++ let rules: Vec<&str> = headers.lines().filter(|l| l.starts_with('/')).collect();1131++ for rule in rules {1132++ assert!(1133++ rule.matches('*').count() <= 1,1134++ "Pages allows one splat per rule: {rule}"1135++ );1136++ }1137++}1138++1139++#[test]1140++fn multibyte_timestamps_do_not_panic() {1141++ let repo = init_repo();1142++ write(&repo, "README.md", "# timestamps\n");1143++ write(1144++ &repo,1145++ ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1146++ "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: \"2026-01-01 12:34:56+€!\"\n---\nbody\n",1147++ );1148++ commit(&repo, "init");1149++ let out = fresh("ts-out");1150++ let res = build(&repo, &out);1151++ assert!(1152++ res.status.success(),1153++ "build panicked? {}",1154++ String::from_utf8_lossy(&res.stderr)1155++ );1156++ let issues = read(&out.join("issues/index.html"));1157++ assert!(issues.contains("€"), "timestamp text preserved");1158++}1159++1160++#[cfg(unix)]1161++#[test]1162++fn symlinked_forge_trees_and_files_are_ignored() {1163++ let secret = fresh("secret");1164++ write(&secret, "wiki/leak.md", "TOP SECRET WIKI\n");1165++ write(1166++ &secret,1167++ "releases/out/v1/leak.html",1168++ "TOP SECRET ARTIFACT\n",1169++ );1170++ write(&secret, "patch.diff", "TOP SECRET PATCH\n");1171++ write(&secret, "steps/00.log", "TOP SECRET LOG\n");1172++1173++ let repo = init_repo();1174++ write(&repo, "README.md", "# symlinks\n");1175++ write(1176++ &repo,1177++ ".forge/releases/v1.md",1178++ "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nnotes\n",1179++ );1180++ write(1181++ &repo,1182++ ".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1183++ "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: P\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1184++ );1185++ write(1186++ &repo,1187++ ".forge/ci/runs/2026-01-01T00-00-00Z/summary.md",1188++ "---\nrun: 2026-01-01T00-00-00Z\nverdict: pass\ncreated: 2026-01-01T00:00:00Z\nsteps: []\n---\nsummary\n",1189++ );1190++ write(&repo, ".forge/ci/steps/00_test.sh", "true\n");1191++ std::fs::create_dir_all(repo.join(".forge/releases/out")).unwrap();1192++ std::os::unix::fs::symlink(secret.join("wiki"), repo.join(".forge/wiki")).unwrap();1193++ std::os::unix::fs::symlink(1194++ secret.join("releases/out/v1"),1195++ repo.join(".forge/releases/out/v1"),1196++ )1197++ .unwrap();1198++ std::os::unix::fs::symlink(1199++ secret.join("patch.diff"),1200++ repo.join(".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff"),1201++ )1202++ .unwrap();1203++ std::fs::create_dir_all(repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps")).unwrap();1204++ std::os::unix::fs::symlink(1205++ secret.join("steps/00.log"),1206++ repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps/00.log"),1207++ )1208++ .unwrap();1209++ commit(&repo, "symlinks");1210++1211++ let out = fresh("symlink-out");1212++ let res = build(&repo, &out);1213++ assert!(1214++ res.status.success(),1215++ "{}",1216++ String::from_utf8_lossy(&res.stderr)1217++ );1218++ let mut hits = Vec::new();1219++ for entry in walk(&out) {1220++ if let Ok(text) = std::fs::read_to_string(&entry) {1221++ if text.contains("TOP SECRET") {1222++ hits.push(entry);1223++ }1224++ }1225++ }1226++ assert!(hits.is_empty(), "host files published: {hits:?}");1227++ assert!(!out.join("wiki/leak.md.html").exists());1228++ assert!(!out.join("releases/v1/leak.html").exists());1229++}1230++1231++#[cfg(unix)]1232++#[test]1233++fn symlinked_forge_root_is_not_read() {1234++ let secret = fresh("root-secret");1235++ write(&secret, "wiki/leak.md", "TOP SECRET ROOT\n");1236++ write(1237++ &secret,1238++ "releases/v1.md",1239++ "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nTOP SECRET ROOT\n",1240++ );1241++ let repo = init_repo();1242++ write(&repo, "README.md", "# root\n");1243++ std::os::unix::fs::symlink(&secret, repo.join(".forge")).unwrap();1244++ commit(&repo, "root symlink");1245++ let out = fresh("root-out");1246++ let res = build(&repo, &out);1247++ assert!(1248++ res.status.success(),1249++ "{}",1250++ String::from_utf8_lossy(&res.stderr)1251++ );1252++ for entry in walk(&out) {1253++ if let Ok(text) = std::fs::read_to_string(&entry) {1254++ assert!(!text.contains("TOP SECRET ROOT"), "{}", entry.display());1255++ }1256++ }1257++}1258++1259++fn walk(root: &Path) -> Vec<PathBuf> {1260++ let mut out = Vec::new();1261++ let mut stack = vec![root.to_path_buf()];1262++ while let Some(dir) = stack.pop() {1263++ let Ok(rd) = std::fs::read_dir(&dir) else {1264++ continue;1265++ };1266++ for ent in rd.flatten() {1267++ let p = ent.path();1268++ if p.is_dir() {1269++ stack.push(p);1270++ } else {1271++ out.push(p);1272++ }1273++ }1274++ }1275++ out1276++}1277++1278++#[test]1279++fn reserved_index_names_do_not_hijack_section_indexes() {1280++ let repo = init_repo();1281++ write(&repo, "README.md", "# indexes\n");1282++ write(1283++ &repo,1284++ ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1285++ "---\nid: index\ntitle: Hijack Issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1286++ );1287++ write(1288++ &repo,1289++ ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",1290++ "---\nid: index\ntitle: Hijack Pull\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",1291++ );1292++ write(1293++ &repo,1294++ ".forge/releases/index.md",1295++ "---\nversion: index\ncreated: 2026-01-01T00:00:00Z\n---\nHijack Release\n",1296++ );1297++ write(&repo, ".forge/wiki/index", "# Hijack Wiki\n");1298++ commit(&repo, "indexes");1299++1300++ let out = fresh("index-out");1301++ let res = build(&repo, &out);1302++ assert!(1303++ res.status.success(),1304++ "{}",1305++ String::from_utf8_lossy(&res.stderr)1306++ );1307++ let stderr = String::from_utf8_lossy(&res.stderr);1308++ assert!(stderr.contains("reserved"), "warned: {stderr}");1309++ for (rel, marker) in [1310++ ("issues/index.html", "Hijack Issue"),1311++ ("pulls/index.html", "Hijack Pull"),1312++ ("releases/index.html", "Hijack Release"),1313++ ("wiki/index.html", "Hijack Wiki"),1314++ ] {1315++ let page = read(&out.join(rel));1316++ assert!(!page.contains(marker), "{rel} hijacked by {marker}");1317++ }1318++ assert!(read(&out.join("issues/index.html")).contains("Issues"));1319++ assert!(read(&out.join("pulls/index.html")).contains("Pulls"));1320++ assert!(read(&out.join("releases/index.html")).contains("Releases"));1321++ assert!(read(&out.join("wiki/index.html")).contains("Wiki"));1322++}1323++1324++#[test]1325++fn markdown_links_images_and_heading_anchors_resolve() {1326++ let repo = init_repo();1327++ write(1328++ &repo,1329++ "README.md",1330++ "# Root\n\n## Section One\n\n[guide](docs/guide.md#section-one) \n\n[web](https://example.com)\n",1331++ );1332++ write(1333++ &repo,1334++ "docs/guide.md",1335++ "# Guide\n\n[peer](peer.md) [root](../README.md) \n",1336++ );1337++ write(&repo, "docs/peer.md", "# Peer\n");1338++ std::fs::create_dir_all(repo.join("docs")).unwrap();1339++ std::fs::write(repo.join("docs/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();1340++ write(1341++ &repo,1342++ ".forge/wiki/home.md",1343++ "# Wiki Home\n\n[other](other.md) \n",1344++ );1345++ write(&repo, ".forge/wiki/other.md", "# Other\n");1346++ std::fs::write(repo.join(".forge/wiki/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();1347++ write(1348++ &repo,1349++ ".forge/releases/v1.md",1350++ "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\n# Release\n\n[guide](docs/guide.md) \n",1351++ );1352++ write(1353++ &repo,1354++ ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",1355++ "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: Link issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\n# Issue\n\n[guide](docs/guide.md) \n",1356++ );1357++ write(1358++ &repo,1359++ ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",1360++ "---\nid: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\ntitle: Link PR\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\n# PR\n\n[guide](docs/guide.md) \n",1361++ );1362++ commit(&repo, "markdown");1363++1364++ let out = fresh("md-out");1365++ let res = build(&repo, &out);1366++ assert!(1367++ res.status.success(),1368++ "{}",1369++ String::from_utf8_lossy(&res.stderr)1370++ );1371++1372++ let index = read(&out.join("index.html"));1373++ assert!(index.contains("id=\"root\""), "{index}");1374++ assert!(index.contains("id=\"section-one\""), "{index}");1375++ assert!(1376++ index.contains("href=\"/files/docs/guide.md.html#section-one\""),1377++ "{index}"1378++ );1379++ assert!(index.contains("src=\"/assets/docs/pic.png\""), "{index}");1380++ assert!(1381++ index.contains("href=\"https://example.com\""),1382++ "external kept"1383++ );1384++1385++ let guide = read(&out.join("files/docs/guide.md.html"));1386++ assert!(guide.contains("id=\"guide\""), "{guide}");1387++ assert!(1388++ guide.contains("href=\"/files/docs/peer.md.html\""),1389++ "{guide}"1390++ );1391++ assert!(guide.contains("href=\"/files/README.md.html\""), "{guide}");1392++ assert!(guide.contains("src=\"/assets/docs/pic.png\""), "{guide}");1393++1394++ let wiki = read(&out.join("wiki/home.md.html"));1395++ assert!(wiki.contains("id=\"wiki-home\""), "{wiki}");1396++ assert!(wiki.contains("href=\"/wiki/other.md.html\""), "{wiki}");1397++ assert!(1398++ wiki.contains("src=\"/assets/.forge/wiki/pic.png\""),1399++ "{wiki}"1400++ );1401++ assert!(out.join("assets/docs/pic.png").is_file());1402++ assert!(out.join("assets/.forge/wiki/pic.png").is_file());1403++1404++ for rel in [1405++ "releases/v1.html",1406++ "issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.html",1407++ "pulls/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.html",1408++ ] {1409++ let page = read(&out.join(rel));1410++ assert!(1411++ page.contains("href=\"/files/docs/guide.md.html\""),1412++ "{rel}: {page}"1413++ );1414++ assert!(1415++ page.contains("src=\"/assets/docs/pic.png\""),1416++ "{rel}: {page}"1417++ );1418++ }1419++}1420++1421++#[cfg(unix)]1422++#[test]1423++fn serve_404_headers_and_bundle_symlinks_are_safe() {1424++ let secret = fresh("serve-secret");1425++ write(&secret, "secret.html", "TOP SECRET 404\n");1426++ let www = fresh("serve-www");1427++ write(&www, "index.html", "<p>ok</p>\n");1428++ std::os::unix::fs::symlink(secret.join("secret.html"), www.join("404.html")).unwrap();1429++1430++ let src = fresh("bundle-src");1431++ git(&src, &["init", "-b", "main"]);1432++ git(&src, &["config", "user.name", "hu13-test"]);1433++ git(&src, &["config", "user.email", "[email protected]"]);1434++ write(&src, "a.txt", "secret\n");1435++ commit(&src, "init");1436++ let bundle = fresh("private-bundle");1437++ git(1438++ &src,1439++ &[1440++ "bundle",1441++ "create",1442++ bundle.join("repo.bundle").to_str().unwrap(),1443++ "main",1444++ ],1445++ );1446++ std::os::unix::fs::symlink(bundle.join("repo.bundle"), www.join("repo.bundle")).unwrap();1447++1448++ let inbox = fresh("serve-inbox");1449++ let srv = start(&www, &inbox);1450++ std::thread::sleep(Duration::from_millis(100));1451++1452++ let root = get(&srv.addr, "/");1453++ assert!(root.starts_with("HTTP/1.1 200"), "{root}");1454++ assert!(1455++ root.contains("Referrer-Policy: strict-origin-when-cross-origin"),1456++ "{root}"1457++ );1458++1459++ let missing = get(&srv.addr, "/nope");1460++ assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");1461++ assert!(!missing.contains("TOP SECRET 404"), "{missing}");1462++ assert!(1463++ missing.contains("Referrer-Policy: strict-origin-when-cross-origin"),1464++ "{missing}"1465++ );1466++1467++ let refs = get(&srv.addr, "/repo.bundle/info/refs?service=git-upload-pack");1468++ assert!(refs.starts_with("HTTP/1.1 404"), "{refs}");1469++ assert!(!refs.contains("git-upload-pack-advertisement"), "{refs}");1470++}1471+-- 1472+2.56.01473+