review #5: harden Pages headers, .forge symlinks, links, origin/static parity (#106-#117)

ee6e71bgl0w

all commits

Message

review #5: harden Pages headers, .forge symlinks, links, origin/static parity (#106-#117)

Diff

.forge/wiki/deploy+5 −0
diff --git a/.forge/wiki/deploy b/.forge/wiki/deployindex ac9b9d3..d13f21c 100644--- a/.forge/wiki/deploy+++ b/.forge/wiki/deploy@@ -70,6 +70,11 @@ What happens inside (`entrypoint.sh`):7070   `/releases/<version>/**` download policy for Pages. On nginx, add the7171   equivalent `add_header Content-Disposition attachment` for `/raw/` and7272   release artifact directories — committed HTML/SVG must never render.73+- Pages clean-URL redirects: Pages answers `.html` paths with a 30874+  (`/issues/index.html` → `/issues/`, `/files/a.md.html` →75+  `/files/a.md`); the links still resolve, and Docker/nginx serve the76+  exact path. `_redirects` status 200 cannot override this. If exact URL77+  shape matters, use a proxy/Worker or accept the Pages form.7378 - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The7479   Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and7580   secret `INBOX_SALT` implement the same contract. See
docs/cloudflare.md+11 −1
diff --git a/docs/cloudflare.md b/docs/cloudflare.mdindex b561c9e..7e6c420 100644--- a/docs/cloudflare.md+++ b/docs/cloudflare.md@@ -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; a55 static-only upload has no working inbox.66 7+Pages normalizes `.html` request paths with a 308 (for example8+`/issues/index.html` → `/issues/` and `/files/a.md.html` →9+`/files/a.md`). Every generated link still reaches the same page; the10+redirect is a Pages platform behavior, not a missing file. Docker/nginx11+serve the exact `.html` path with no redirect. If byte-identical URLs12+matter, put a proxy/Worker in front or accept the Pages canonical form;13+`_redirects` status 200 cannot override these clean-URL redirects.14+715 ## 1. Build the site816 917 ```sh@@ -94,4 +102,6 @@ it does not read R2. Copy objects down if you want the terminal reader.94102 - Committed `/raw/*` bytes and release artifacts under95103   `/releases/<version>/*` always download (`Content-Disposition:96104   attachment`) on both `serve` and Pages, so hostile HTML/SVG can never97-  execute in the inbox origin.105+  execute in the inbox origin. The Pages `_headers` rule uses one named106+  placeholder plus one splat (`/releases/:version/*`): Pages silently107+  skips rules with two splats.
functions/inbox/[kind].js+22 −3
diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].jsindex 1fa9037..3ef6121 100644--- a/functions/inbox/[kind].js+++ b/functions/inbox/[kind].js@@ -258,13 +258,17 @@ async function saltedIpHash(ip, salt) {258258 // rule for /inbox/* is the durable control; this still stops a single259259 // hot isolate from storing unlimited submissions.260260 const buckets = new Map();261+const MAX_RATE_KEYS = 10000;261262 function rateAllowed(key, limit) {262263   const now = Date.now();263-  if (buckets.size > 10000) {264+  if (buckets.size >= MAX_RATE_KEYS) {264265     for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k);265266   }266267   let b = buckets.get(key);267268   if (!b || b.reset <= now) {269+    // Fail closed for a new key when the isolate map is full; do not let270+    // source-IP rotation grow it without bound (f0rge #116).271+    if (!b && buckets.size >= MAX_RATE_KEYS) return false;268272     b = { count: 0, reset: now + 3600_000 };269273     buckets.set(key, b);270274   }@@ -273,9 +277,24 @@ function rateAllowed(key, limit) {273277   return true;274278 }275279 280+// R2 list returns at most 1000 objects per call. Paginate until the281+// configured quota is counted or the listing ends; a truncated page282+// without a cursor fails closed (f0rge #116).276283 async function overQuota(bucket, limit) {277-  const listed = await bucket.list({ limit: Math.min(limit, 1000) });278-  return listed.objects.length >= limit;284+  if (!(limit > 0)) return true;285+  let count = 0;286+  let cursor;287+  for (;;) {288+    const page = await bucket.list({289+      limit: Math.min(limit - count, 1000),290+      cursor,291+    });292+    count += page.objects.length;293+    if (count >= limit) return true;294+    if (!page.truncated) return false;295+    if (!page.cursor) return true;296+    cursor = page.cursor;297+  }279298 }280299 281300 function nowRfc3339() {
src/build.rs+245 −17
diff --git a/src/build.rs b/src/build.rsindex 4274e88..a572829 100644--- a/src/build.rs+++ b/src/build.rs@@ -191,14 +191,184 @@ fn safe_url(url: &str) -> bool {191191     matches!(scheme, "http" | "https" | "mailto" | "tel")192192 }193193 194-/// Render markdown to HTML, escaping raw HTML.195-///196-/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched197-/// (R1 finding), so map them through [`esc`] first. GFM tables, task198-/// lists and strikethrough are enabled (f0rge #71); link/image199-/// destinations are scheme-filtered (f0rge #62).200-#[must_use]201-pub fn markdown_to_html(md: &str) -> String {194+/// Context for relative markdown destinations: a repo file page uses195+/// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses196+/// `/wiki/...` + raw/assets for non-page files.197+#[derive(Clone, Copy)]198+enum LinkBase<'a> {199+    Repo(&'a str),200+    Wiki(&'a str),201+}202+203+fn base_path(base: LinkBase<'_>) -> &str {204+    match base {205+        LinkBase::Repo(p) | LinkBase::Wiki(p) => p,206+    }207+}208+209+/// Lexically resolve a relative link against a repo- or wiki-relative210+/// base directory. Returns `None` when it climbs above the root or has211+/// unsafe/absolute components.212+fn rel_join(base: &str, path: &str) -> Option<String> {213+    if path.starts_with('/') || path.contains(['\\', '\0']) {214+        return None;215+    }216+    let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();217+    for seg in path.split('/') {218+        match seg {219+            "" | "." => {}220+            ".." => {221+                parts.pop()?;222+            }223+            other => parts.push(other),224+        }225+    }226+    if parts.is_empty() {227+        return None;228+    }229+    Some(parts.join("/"))230+}231+232+/// Extensions safe to display inline from `assets/`: raster/static233+/// images whose bytes cannot execute as a document. SVG and HTML stay234+/// downloads under `raw/`.235+fn is_inline_image(path: &str) -> bool {236+    let base = path.rsplit('/').next().unwrap_or(path);237+    let Some((stem, ext)) = base.rsplit_once('.') else {238+        return false;239+    };240+    !stem.is_empty()241+        && matches!(242+            ext.to_ascii_lowercase().as_str(),243+            "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif"244+        )245+}246+247+/// Rewrite one relative markdown destination to the generated route.248+/// External URLs, fragments, absolute paths and anything that cannot be249+/// resolved stay as-is. Query/fragment suffixes are preserved.250+fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option<String> {251+    let d = dest.trim();252+    if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") {253+        return None;254+    }255+    // A real scheme (`http:`, `mailto:`, ...) is not a relative file.256+    if let Some(colon) = d.find(':') {257+        let scheme = &d[..colon];258+        let mut chars = scheme.chars();259+        if chars.next().is_some_and(|c| c.is_ascii_alphabetic())260+            && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))261+        {262+            return None;263+        }264+    }265+    let cut = d.find(['?', '#']).unwrap_or(d.len());266+    let (path, suffix) = d.split_at(cut);267+    let target = rel_join(base_path(base), path)?;268+    let target = target.trim_end_matches('/');269+    if target.is_empty() {270+        return None;271+    }272+    let enc = encode_path(target);273+    let asset = match base {274+        LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"),275+        LinkBase::Repo(_) => format!("/assets/{enc}"),276+    };277+    let route = if is_inline_image(target) {278+        asset279+    } else if image {280+        format!("/raw/{enc}")281+    } else {282+        match base {283+            LinkBase::Wiki(_) => format!("/wiki/{enc}.html"),284+            LinkBase::Repo(_) => {285+                let name = target.rsplit('/').next().unwrap_or(target);286+                if is_markdown(target) || name.eq_ignore_ascii_case("readme") {287+                    format!("/files/{enc}.html")288+                } else {289+                    format!("/raw/{enc}")290+                }291+            }292+        }293+    };294+    Some(format!("{route}{suffix}"))295+}296+297+/// Strip simple inline/block tags for a heading slug. Entity decoding298+/// runs afterwards so `&amp;` becomes a word separator, not `amp`.299+fn heading_text(inner: &str) -> String {300+    let mut out = String::with_capacity(inner.len());301+    let mut in_tag = false;302+    for c in inner.chars() {303+        match c {304+            '<' => in_tag = true,305+            '>' => in_tag = false,306+            _ if !in_tag => out.push(c),307+            _ => {}308+        }309+    }310+    decode_entities(&out)311+}312+313+fn slugify(text: &str) -> String {314+    let mut out = String::new();315+    let mut dash = false;316+    for c in text.chars().flat_map(char::to_lowercase) {317+        if c.is_alphanumeric() {318+            out.push(c);319+            dash = false;320+        } else if !out.is_empty() && !dash {321+            out.push('-');322+            dash = true;323+        }324+    }325+    while out.ends_with('-') {326+        out.pop();327+    }328+    if out.is_empty() {329+        "section".to_string()330+    } else {331+        out332+    }333+}334+335+fn heading_parts(line: &str) -> Option<(usize, &str, &str)> {336+    let b = line.as_bytes();337+    if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>'338+    {339+        return None;340+    }341+    let level = usize::from(b[2] - b'0');342+    let close = format!("</h{level}>");343+    let end = line.find(&close)?;344+    Some((level, &line[4..end], &line[end + close.len()..]))345+}346+347+/// Add deterministic heading ids so `#section` links work. Input is the348+/// renderer's own output, so headings are one per line and contain only349+/// generated tags; code-block lines are entity-escaped and never match.350+fn add_heading_ids(html: &str) -> String {351+    let mut out = String::with_capacity(html.len() + 64);352+    let mut seen: BTreeMap<String, usize> = BTreeMap::new();353+    for line in html.split_inclusive('\n') {354+        if let Some((level, inner, tail)) = heading_parts(line) {355+            let base = slugify(&heading_text(inner));356+            let count = seen.entry(base.clone()).or_insert(0);357+            *count += 1;358+            let id = if *count == 1 {359+                base360+            } else {361+                format!("{base}-{}", *count)362+            };363+            out.push_str(&format!("<h{level} id=\"{id}\">{inner}</h{level}>{tail}"));364+        } else {365+            out.push_str(line);366+        }367+    }368+    out369+}370+371+fn render_markdown(md: &str, base: Option<LinkBase<'_>>) -> String {202372     use pulldown_cmark::{html, Event, Options, Parser, Tag};203373     let options = Options::ENABLE_TABLES204374         | Options::ENABLE_TASKLISTS@@ -215,7 +385,11 @@ pub fn markdown_to_html(md: &str) -> String {215385             id,216386         }) => {217387             let dest = if safe_url(&dest_url) {218-                decode_entities(&dest_url)388+                let decoded = decode_entities(&dest_url);389+                match base.and_then(|b| rewrite_dest(&decoded, b, false)) {390+                    Some(rewritten) => rewritten,391+                    None => decoded,392+                }219393             } else {220394                 "#".to_string()221395             };@@ -233,7 +407,11 @@ pub fn markdown_to_html(md: &str) -> String {233407             id,234408         }) => {235409             let dest = if safe_url(&dest_url) {236-                decode_entities(&dest_url)410+                let decoded = decode_entities(&dest_url);411+                match base.and_then(|b| rewrite_dest(&decoded, b, true)) {412+                    Some(rewritten) => rewritten,413+                    None => decoded,414+                }237415             } else {238416                 "#".to_string()239417             };@@ -249,7 +427,31 @@ pub fn markdown_to_html(md: &str) -> String {249427     let mut out = String::from("<div class=\"markdown\">\n");250428     html::push_html(&mut out, mapped);251429     out.push_str("</div>\n");252-    out430+    add_heading_ids(&out)431+}432+433+/// Render markdown to HTML, escaping raw HTML.434+///435+/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched436+/// (R1 finding), so map them through [`esc`] first. GFM tables, task437+/// lists and strikethrough are enabled (f0rge #71); link/image438+/// destinations are scheme-filtered (f0rge #62). Headings get stable439+/// ids so `#section` links resolve (f0rge #112).440+#[must_use]441+pub fn markdown_to_html(md: &str) -> String {442+    render_markdown(md, None)443+}444+445+/// Render markdown from a repo file page or wiki page, rewriting446+/// relative links/images to generated routes (f0rge #112).447+#[must_use]448+pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String {449+    let base = if wiki {450+        LinkBase::Wiki(base)451+    } else {452+        LinkBase::Repo(base)453+    };454+    render_markdown(md, Some(base))253455 }254456 255457 /// Resolve the clone URL to advertise.@@ -547,6 +749,12 @@ pub fn now_unix() -> i64 {547749 #[must_use]548750 pub fn parse_ts_secs(s: &str) -> Option<i64> {549751     let s = s.trim();752+    // Timestamps are ASCII by definition. Reject other text before any753+    // byte-index slicing: a 200-char multibyte `created` used to panic754+    // on a non-char-boundary (f0rge #108).755+    if !s.is_ascii() {756+        return None;757+    }550758     // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's551759     // `2026-10-27 14:08:48 +0000` / `+00:00` forms.552760     let (date, rest) = match s.split_once('T') {@@ -1154,7 +1362,10 @@ pub const HEADERS_FILE: &str = "\11541362   X-Content-Type-Options: nosniff11551363 # Artifacts are build outputs copied from .forge/releases/out/<ver>/;11561364 # never let an artifact .html/.svg execute in the site origin (f0rge #97).1157-/releases/*/*1365+# Cloudflare Pages allows one splat per rule. A named `:version`1366+# placeholder plus one splat covers every artifact depth (f0rge #106).1367+# A two-splat rule is silently skipped by Pages and leaves artifacts active.1368+/releases/:version/*11581369   Content-Disposition: attachment11591370   X-Content-Type-Options: nosniff11601371 /*@@ -1486,6 +1697,17 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu14861697             std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;14871698             blobs.insert(f.clone(), blob.bytes);14881699         }1700+        // Raster images mentioned by markdown need an inline route; `/raw`1701+        // intentionally downloads everything. Copy only non-executable1702+        // image formats under `/assets` (f0rge #112). SVG/HTML stay raw.1703+        if is_inline_image(f) {1704+            let adest = out.join("assets").join(f);1705+            if let Some(parent) = adest.parent() {1706+                std::fs::create_dir_all(parent)1707+                    .map_err(|e| format!("hu13: create assets parent: {e}"))?;1708+            }1709+            std::fs::copy(&dest, &adest).map_err(|e| format!("hu13: write asset {f}: {e}"))?;1710+        }14891711     }14901712 14911713     // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file@@ -1591,7 +1813,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu15911813                     bytes.len() as u64,15921814                     Some(display.lines().count()),15931815                 ));1594-                body.push_str(&markdown_to_html(&display));1816+                let base = f.rsplit_once('/').map(|(d, _)| d).unwrap_or("");1817+                body.push_str(&markdown_to_html_in(&display, base, false));15951818                 body.push_str(&format!(15961819                     "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",15971820                     href_esc(f)@@ -1733,7 +1956,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu17331956                 esc(path)17341957             ));17351958             if is_markdown(path) || path.to_lowercase().contains("readme") {1736-                body.push_str(&markdown_to_html(&blob.display));1959+                let (base, wiki) = if let Some(rest) = path.strip_prefix(".forge/wiki/") {1960+                    (rest.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), true)1961+                } else {1962+                    (path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""), false)1963+                };1964+                body.push_str(&markdown_to_html_in(&blob.display, base, wiki));17371965             } else {17381966                 body.push_str(&render_text_pre(&blob.display, path));17391967             }@@ -2070,7 +2298,7 @@ mod tests {20702298         let (_repo, out) = build_fixture();20712299         let html = std::fs::read_to_string(out.join("files/docs/guide.md.html")).expect("md page");20722300         assert!(2073-            html.contains("<h1>Guide</h1>"),2301+            html.contains("<h1 id=\"guide\">Guide</h1>"),20742302             "markdown rendered, got: {html}"20752303         );20762304         assert!(html.contains("/raw/docs/guide.md"), "raw link kept");@@ -2243,7 +2471,7 @@ mod tests {22432471     #[test]22442472     fn markdown_escapes_raw_html() {22452473         let html = markdown_to_html("# hi\n\n<script>alert(1)</script>\n\n**bold**\n");2246-        assert!(html.contains("<h1>hi</h1>"), "heading renders");2474+        assert!(html.contains("<h1 id=\"hi\">hi</h1>"), "heading renders");22472475         assert!(!html.contains("<script>"), "no raw passthrough");22482476         assert!(html.contains("&lt;script&gt;"), "escaped instead");22492477         assert!(@@ -3035,6 +3263,6 @@ mod tests {30353263         // Agents guide renders its markdown h1; spacing comes from the30363264         // shared `main>h1:first-child` gap-var rule (CSS assert in layout).30373265         let agents = read("agents.html");3038-        assert!(agents.contains("<h1>"), "agents h1");3266+        assert!(agents.contains("<h1"), "agents h1");30393267     }30403268 }
src/forge.rs+63 −14
diff --git a/src/forge.rs b/src/forge.rsindex 9da9bd1..5201c45 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -22,7 +22,7 @@ use std::path::Path;2222 2323 use serde::Deserialize;2424 25-use crate::build::markdown_to_html;25+use crate::build::markdown_to_html_in;2626 use crate::layout::{diff_html, esc, page_site_with_provenance};2727 2828 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).@@ -89,6 +89,35 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {8989     }9090 }9191 92+/// Like [`read_text_capped`], but keeps arbitrary bytes (CI logs may be93+/// lossy UTF-8). Symlinks and oversized files are skipped, so `.forge`94+/// cannot make the build read an unbounded or outside file (f0rge #107).95+fn read_bytes_capped(path: &Path, label: &str) -> Option<Vec<u8>> {96+    match std::fs::symlink_metadata(path) {97+        Ok(m) if m.file_type().is_symlink() => {98+            eprintln!("hu13: forge: skipping {label}: symlink");99+            return None;100+        }101+        _ => {}102+    }103+    let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);104+    if len > FORGE_MAX_BYTES {105+        eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");106+        return None;107+    }108+    match std::fs::read(path) {109+        Ok(bytes) if bytes.len() as u64 <= FORGE_MAX_BYTES => Some(bytes),110+        Ok(_) => {111+            eprintln!("hu13: forge: skipping {label}: over 256 KiB");112+            None113+        }114+        Err(e) => {115+            eprintln!("hu13: forge: skipping {label}: cannot read: {e}");116+            None117+        }118+    }119+}120+92121 /// Parse YAML frontmatter; on error warn (naming file via `label` plus the93122 /// serde error, which names the field) and return `None` (skip the file).94123 fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>@@ -120,8 +149,13 @@ fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {120149                 continue;121150             }122151         };123-        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);124-        if is_dir != only_dirs {152+        let meta = match std::fs::symlink_metadata(ent.path()) {153+            Ok(m) => m,154+            Err(_) => continue,155+        };156+        // Never descend or read through a symlink: `.forge` entries are157+        // repo-controlled and must not escape the checkout (f0rge #107).158+        if meta.file_type().is_symlink() || meta.is_dir() != only_dirs {125159             continue;126160         }127161         names.push(ent.file_name().to_string_lossy().into_owned());@@ -592,6 +626,10 @@ fn load_issues(root: &Path) -> Vec<Issue> {592626             eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);593627             continue;594628         }629+        if crate::paths::is_reserved_index(&fm.id) {630+            eprintln!("hu13: forge: skipping {label}: reserved id index");631+            continue;632+        }595633         let comments = load_comments(596634             &dir.join(&uuid).join("comments"),597635             &format!("issues/{uuid}/comments"),@@ -637,9 +675,13 @@ fn load_prs(root: &Path) -> Vec<Pr> {637675             eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);638676             continue;639677         }640-        let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))641-            .ok()642-            .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());678+        if crate::paths::is_reserved_index(&fm.id) {679+            eprintln!("hu13: forge: skipping {label}: reserved id index");680+            continue;681+        }682+        // Symlink-safe and bounded: a hostile patch.diff used to be read683+        // whole, even through a symlink (f0rge #107).684+        let patch = read_text_capped(&dir.join(&uuid).join("patch.diff"), "patch.diff");643685         let comments = load_comments(644686             &dir.join(&uuid).join("comments"),645687             &format!("prs/{uuid}/comments"),@@ -839,8 +881,12 @@ fn load_ci(root: &Path) -> CiInfo {839881             if !name.ends_with(".log") {840882                 continue;841883             }842-            let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))843-                .unwrap_or_default();884+            let label = format!("ci/runs/{ts}/steps/{name}");885+            let bytes = read_bytes_capped(886+                &dir.join("runs").join(&ts).join("steps").join(&name),887+                &label,888+            )889+            .unwrap_or_default();844890             let text = String::from_utf8_lossy(&bytes);845891             logs.push((name, truncate_log(&redact_paths(&text, repo_root))));846892         }@@ -874,7 +920,10 @@ fn load_ci(root: &Path) -> CiInfo {874920 /// `.forge/` yields empty sections.875921 #[must_use]876922 pub fn load(repo: &Path) -> ForgeData {877-    let root = repo.join(".forge");923+    // Resolve `.forge` and require it to stay inside the repo. A symlinked924+    // `.forge` used to publish host issues/wiki/releases (f0rge #107).925+    let root = crate::paths::repo_subdir(repo, ".forge")926+        .unwrap_or_else(|| repo.join(".hu13-missing-forge"));878927     ForgeData {879928         issues: load_issues(&root),880929         prs: load_prs(&root),@@ -971,7 +1020,7 @@ fn render_comment(c: &Comment, now: i64) -> String {9711020         esc(author),9721021         time_ago(&c.created, now),9731022     ));974-    out.push_str(&markdown_to_html(&c.body));1023+    out.push_str(&markdown_to_html_in(&c.body, "", false));9751024     out.push_str("</li>\n");9761025     out9771026 }@@ -1022,7 +1071,7 @@ fn render_issue_page(10221071         esc(&issue.id)10231072     ));10241073     body.push_str(&details);1025-    body.push_str(&markdown_to_html(&issue.body));1074+    body.push_str(&markdown_to_html_in(&issue.body, "", false));10261075     body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));10271076     if issue.comments.is_empty() {10281077         body.push_str("<p class=\"muted\">No comments yet.</p>\n");@@ -1096,7 +1145,7 @@ fn render_pr_page(10961145         esc(&pr.id)10971146     ));10981147     body.push_str(&details);1099-    body.push_str(&markdown_to_html(&pr.body));1148+    body.push_str(&markdown_to_html_in(&pr.body, "", false));11001149     body.push_str("<h2>Patch</h2>\n");11011150     match &pr.patch {11021151         Some(patch) if !patch.is_empty() => {@@ -1131,7 +1180,7 @@ fn render_pr_page(11311180                 esc(author),11321181                 time_ago(&r.created, now),11331182                 esc(state),1134-                markdown_to_html(&r.body),1183+                markdown_to_html_in(&r.body, "", false),11351184             ));11361185         }11371186         body.push_str("</ul>\n");@@ -1312,7 +1361,7 @@ fn render_tasks_index(13121361                             "<p class=\"muted\">assignee: <code>{}</code></p>\n",13131362                             esc(a)13141363                         )),1315-                        body_md = markdown_to_html(&t.body),1364+                        body_md = markdown_to_html_in(&t.body, "", false),13161365                     ));13171366                 }13181367                 body.push_str("</ul>\n");
src/paths.rs+60 −1
diff --git a/src/paths.rs b/src/paths.rsindex 437caaa..049b015 100644--- a/src/paths.rs+++ b/src/paths.rs@@ -7,7 +7,11 @@77 //!88 //! Rules: no absolute paths, no `\` or NUL, no empty/`.`/`..` segments.99 //! The checks are lexical on purpose — they run before anything is10-//! created, so there is nothing to canonicalize yet.10+//! created, so there is nothing to canonicalize yet. Filesystem readers11+//! additionally use [`repo_subdir`] so a symlinked `.forge` cannot point12+//! outside the checkout (f0rge #107).13+14+use std::path::{Path, PathBuf};1115 1216 /// `true` when `segment` is a single safe path component.1317 #[must_use]@@ -24,6 +28,29 @@ pub fn is_safe_relative(rel: &str) -> bool {2428         && rel.split('/').all(is_safe_segment)2529 }2630 31+/// Resolve `repo/rel` through symlinks and require the result to stay32+/// inside the resolved repo root. Returns `None` for missing paths,33+/// symlink escapes, or the repo root itself. Used for untrusted34+/// `.forge` trees (f0rge #107).35+#[must_use]36+pub fn repo_subdir(repo: &Path, rel: &str) -> Option<PathBuf> {37+    let root = std::fs::canonicalize(repo).ok()?;38+    let target = std::fs::canonicalize(root.join(rel)).ok()?;39+    if target == root || !target.starts_with(&root) {40+        return None;41+    }42+    Some(target)43+}44+45+/// `true` for names that would overwrite a generated section index46+/// (`releases/index.html`, `issues/index.html`, `pulls/index.html`,47+/// `wiki/index.html`). Case-insensitive so a case-insensitive host48+/// cannot collide either (f0rge #110).49+#[must_use]50+pub fn is_reserved_index(name: &str) -> bool {51+    name.eq_ignore_ascii_case("index")52+}53+2754 /// `true` when `id` is safe both as a path segment and as a displayed2855 /// forge id: ASCII alphanumerics, `-`, `_` only.2956 #[must_use]@@ -71,4 +98,36 @@ mod tests {7198             assert!(!is_safe_id(bad), "{bad:?} must be rejected");7299         }73100     }101+102+    #[test]103+    fn reserved_index_names() {104+        for name in ["index", "INDEX", "Index"] {105+            assert!(is_reserved_index(name));106+        }107+        for name in ["index.html", "indexes", "my-index", ""] {108+            assert!(!is_reserved_index(name));109+        }110+    }111+112+    #[test]113+    fn subdir_stays_inside_repo() {114+        let dir = std::env::temp_dir().join(format!(115+            "hu13-paths-{}-{}",116+            std::process::id(),117+            std::time::SystemTime::now()118+                .duration_since(std::time::UNIX_EPOCH)119+                .map(|d| d.as_nanos())120+                .unwrap_or(0)121+        ));122+        std::fs::create_dir_all(dir.join("inside")).unwrap();123+        std::fs::write(dir.join("inside/x"), "x").unwrap();124+        assert!(repo_subdir(&dir, "inside").is_some());125+        assert!(repo_subdir(&dir, "missing").is_none());126+        #[cfg(unix)]127+        {128+            std::os::unix::fs::symlink("/etc", dir.join("escape")).unwrap();129+            assert!(repo_subdir(&dir, "escape").is_none());130+        }131+        let _ = std::fs::remove_dir_all(&dir);132+    }74133 }
src/releases.rs+27 −9
diff --git a/src/releases.rs b/src/releases.rsindex 75acf0c..c9b9d28 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};2121 2222 use serde::Deserialize;2323 24-use crate::build::{href_esc, markdown_to_html};24+use crate::build::{href_esc, markdown_to_html_in};2525 use crate::layout::{esc, page_site_with_provenance};2626 2727 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).@@ -125,6 +125,11 @@ fn read_text_capped(path: &Path, label: &str) -> Option<String> {125125 /// Collect every file under `dir` as `(relative /-separated path, size)`,126126 /// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.127127 fn collect_artifacts(dir: &Path) -> Vec<Artifact> {128+    // The version directory itself may be a symlink; never list outside.129+    match std::fs::symlink_metadata(dir) {130+        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}131+        _ => return Vec::new(),132+    }128133     let mut out = Vec::new();129134     let mut stack: Vec<PathBuf> = vec![PathBuf::new()];130135     while let Some(rel) = stack.pop() {@@ -179,6 +184,10 @@ fn collect_artifacts(dir: &Path) -> Vec<Artifact> {179184 180185 /// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).181186 fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {187+    match std::fs::symlink_metadata(src_dir) {188+        Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}189+        _ => return Ok(()),190+    }182191     let mut stack: Vec<PathBuf> = vec![PathBuf::new()];183192     while let Some(rel) = stack.pop() {184193         let src = src_dir.join(&rel);@@ -226,7 +235,9 @@ fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {226235 /// an empty vec. Order is `(created, version)`.227236 #[must_use]228237 pub fn load(repo: &Path) -> Vec<Release> {229-    let root = repo.join(".forge").join("releases");238+    let Some(root) = crate::paths::repo_subdir(repo, ".forge/releases") else {239+        return Vec::new();240+    };230241     let rd = match std::fs::read_dir(&root) {231242         Ok(rd) => rd,232243         Err(_) => return Vec::new(),@@ -280,6 +291,10 @@ pub fn load(repo: &Path) -> Vec<Release> {280291             );281292             continue;282293         }294+        if crate::paths::is_reserved_index(&fm.version) {295+            eprintln!("hu13: releases: skipping {label}: reserved version index");296+            continue;297+        }283298         // Artifacts: present only when `out/<version>/` exists at build.284299         // Absent -> notes-only, no warning.285300         let out_dir = root.join("out").join(&fm.version);@@ -366,7 +381,7 @@ fn render_release_page(366381             esc(sha)367382         ));368383     }369-    body.push_str(&markdown_to_html(&rel.body));384+    body.push_str(&markdown_to_html_in(&rel.body, "", false));370385     if rel.artifacts.is_empty() {371386         body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");372387     } else {@@ -428,7 +443,7 @@ pub fn render_loaded(428443     short_sha: &str,429444     description: Option<&str>,430445 ) -> Result<(), String> {431-    let root = repo.join(".forge").join("releases");446+    let root = crate::paths::repo_subdir(repo, ".forge/releases");432447     let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");433448     if releases.is_empty() {434449         body.push_str("<p class=\"muted\">No releases yet.</p>\n");@@ -472,11 +487,14 @@ pub fn render_loaded(472487                 rel.version473488             ));474489         }475-        // Copy the whole out dir when present (no size cap).476-        let src_dir = root.join("out").join(&rel.version);477-        if src_dir.is_dir() {478-            let dest_dir = out.join("releases").join(&rel.version);479-            copy_tree(&src_dir, &dest_dir)?;490+        // Copy the whole out dir when present (no size cap). The root is491+        // resolved once; copy_tree still refuses a symlinked version dir.492+        if let Some(root) = root.as_ref() {493+            let src_dir = root.join("out").join(&rel.version);494+            if src_dir.is_dir() {495+                let dest_dir = out.join("releases").join(&rel.version);496+                copy_tree(&src_dir, &dest_dir)?;497+            }480498         }481499         let html = render_release_page(rel, site_name, nav, branch, short_sha, description);482500         let page_dest = out.join("releases").join(format!("{}.html", rel.version));
src/serve.rs+16 −5
diff --git a/src/serve.rs b/src/serve.rsindex 9edb446..89bf867 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -110,8 +110,12 @@ struct Shared {110110 /// startup. Returns `None` when no bundle exists or git fails (serve111111 /// continues static-only; `/repo.bundle` still downloads via GET).112112 fn prepare_git_dir(www: &Path) -> Option<PathBuf> {113-    let bundle = www.join("repo.bundle");114-    if !bundle.is_file() {113+    // Static serving already rejects an outside symlink; smart HTTP must114+    // use the same resolved-path check (f0rge #117). A symlink pointing115+    // inside www is allowed, but a symlink to a private bundle is not.116+    let www_root = resolve_existing(www);117+    let bundle = resolve_existing(&www.join("repo.bundle"));118+    if bundle == www_root || !bundle.starts_with(&www_root) || !bundle.is_file() {115119         return None;116120     }117121     let dir = std::env::temp_dir().join(format!(@@ -428,7 +432,7 @@ fn respond(428432     body: &[u8],429433 ) {430434     let mut out = format!(431-        "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",435+        "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",432436         reason(code),433437         body.len()434438     );@@ -492,6 +496,8 @@ fn content_type_for(name: &str) -> &'static str {492496         "image/webp"493497     } else if lower.ends_with(".ico") {494498         "image/x-icon"499+    } else if lower.ends_with(".avif") {500+        "image/avif"495501     } else if lower.ends_with(".webmanifest") {496502         "application/manifest+json"497503     } else if lower.ends_with(".json") {@@ -888,7 +894,7 @@ fn respond_file_head(888894     len: u64,889895 ) {890896     let mut out = format!(891-        "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",897+        "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",892898         reason(code)893899     );894900     for (k, v) in extra {@@ -910,7 +916,12 @@ fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {910916         static_error(stream, 404);911917         return;912918     }913-    let dest = shared.www.join("404.html");919+    let www = resolve_existing(&shared.www);920+    let dest = resolve_existing(&shared.www.join("404.html"));921+    if dest == www || !dest.starts_with(&www) {922+        static_error(stream, 404);923+        return;924+    }914925     let Ok(meta) = std::fs::metadata(&dest) else {915926         static_error(stream, 404);916927         return;
src/wiki.rs+9 −6
diff --git a/src/wiki.rs b/src/wiki.rsindex 25bfc48..8465473 100644--- a/src/wiki.rs+++ b/src/wiki.rs@@ -14,7 +14,7 @@1414 use std::collections::BTreeMap;1515 use std::path::Path;1616 17-use crate::build::{href_esc, markdown_to_html};17+use crate::build::{href_esc, markdown_to_html_in};1818 use crate::layout::{esc, page_site_with_provenance};1919 2020 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).@@ -75,7 +75,8 @@ pub fn page_title(body: &str, filename: &str) -> String {7575 /// [`render`] would emit for skipped entries.7676 #[must_use]7777 pub fn page_count(repo: &Path) -> usize {78-    let root = repo.join(".forge").join("wiki");78+    let root = crate::paths::repo_subdir(repo, ".forge/wiki")79+        .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));7980     if !root.exists() {8081         return 0;8182     }@@ -128,7 +129,7 @@ pub fn page_count(repo: &Path) -> usize {128129             };129130             if meta.is_dir() {130131                 stack.push(rel);131-            } else if meta.is_file() {132+            } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {132133                 rel_files.push(rel);133134             }134135         }@@ -169,7 +170,8 @@ pub fn render(169170     short_sha: &str,170171     description: Option<&str>,171172 ) -> Result<Vec<WikiPage>, String> {172-    let root = repo.join(".forge").join("wiki");173+    let root = crate::paths::repo_subdir(repo, ".forge/wiki")174+        .unwrap_or_else(|| repo.join(".hu13-missing-wiki"));173175     let mut rel_files: Vec<String> = Vec::new();174176     if root.exists() {175177         // Iterative walk over `/`-separated relative dirs. Symlinked dirs@@ -226,7 +228,7 @@ pub fn render(226228                 };227229                 if meta.is_dir() {228230                     stack.push(rel);229-                } else if meta.is_file() {231+                } else if meta.is_file() && !crate::paths::is_reserved_index(&rel) {230232                     rel_files.push(rel);231233                 }232234             }@@ -317,7 +319,8 @@ pub fn render(317319             esc(&p.title),318320             esc(&p.path),319321         );320-        body.push_str(&markdown_to_html(&text));322+        let base = p.path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");323+        body.push_str(&markdown_to_html_in(&text, base, true));321324         let html = page_site_with_provenance(322325             &format!("{} — wiki — {site_name}", p.title),323326             nav,
tests/pages_inbox.test.mjs+18 −3
diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjsindex a825c71..1696bae 100644--- a/tests/pages_inbox.test.mjs+++ b/tests/pages_inbox.test.mjs@@ -10,10 +10,16 @@ class FakeBucket {1010   constructor() {1111     this.objects = new Map();1212   }13-  async list({ limit = 1000 } = {}) {13+  async list({ limit = 1000, cursor } = {}) {14+    const keys = [...this.objects.keys()];15+    const start = cursor === undefined ? 0 : Number(cursor);16+    const objects = keys.slice(start, start + limit).map((key) => ({ key }));17+    const next = start + objects.length;18+    const truncated = next < keys.length;1419     return {15-      objects: [...this.objects.keys()].slice(0, limit).map((key) => ({ key })),16-      truncated: this.objects.size > limit,20+      objects,21+      truncated,22+      cursor: truncated ? String(next) : undefined,1723     };1824   }1925   async put(key, value) {@@ -159,6 +165,15 @@ test("quota is enforced before storing", async () => {159165   assert.equal(e.INBOX.objects.size, 1);160166 });161167 168+test("quota above 1000 paginates R2 listings", async () => {169+  const bucket = new FakeBucket();170+  for (let i = 0; i < 1500; i++) bucket.objects.set(`old-${i}.json`, "x");171+  const e = env({ INBOX: bucket, INBOX_QUOTA: "1500" });172+  const { res } = await post("issue", validIssue, {}, e);173+  assert.equal(res.status, 503);174+  assert.equal(bucket.objects.size, 1500, "full inbox did not store");175+});176+162177 test("missing R2 binding or salt fails closed", async () => {163178   const noBucket = await post("issue", validIssue, {}, { INBOX_SALT: "x" });164179   assert.equal(noBucket.res.status, 503);
tests/review4_hardening.rs+5 −1
diff --git a/tests/review4_hardening.rs b/tests/review4_hardening.rsindex 961f0bc..371bdc6 100644--- a/tests/review4_hardening.rs+++ b/tests/review4_hardening.rs@@ -222,7 +222,11 @@ fn review4_build_and_release_hardening() {222222     );223223 224224     let headers = read(&out.join("_headers"));225-    assert!(headers.contains("/releases/*/*"), "{headers}");225+    assert!(headers.contains("/releases/:version/*"), "{headers}");226+    assert!(227+        !headers.contains("/releases/*/*"),228+        "two splats are invalid on Pages: {headers}"229+    );226230     assert!(227231         headers.contains("Content-Disposition: attachment"),228232         "{headers}"
tests/review5_hardening.rs+477 −0
diff --git a/tests/review5_hardening.rs b/tests/review5_hardening.rsnew file mode 100644index 0000000..9756eaa--- /dev/null+++ b/tests/review5_hardening.rs@@ -0,0 +1,477 @@1+//! Review #5 hardening (f0rge #106–#117): one-splat Pages artifact2+//! headers, symlink-safe `.forge` reads, multibyte timestamps, reserved3+//! indexes, context-aware markdown links/images/anchors, and `serve`4+//! header/404/bundle parity.5+6+use std::io::{BufRead, BufReader, Read, Write};7+use std::net::TcpStream;8+use std::path::{Path, PathBuf};9+use std::process::{Child, Command, Stdio};10+use std::sync::atomic::{AtomicU64, Ordering};11+use std::time::Duration;12+13+static SEQ: AtomicU64 = AtomicU64::new(0);14+15+fn fresh(prefix: &str) -> PathBuf {16+    let dir = std::env::temp_dir().join(format!(17+        "hu13-review5-{prefix}-{}-{}",18+        std::process::id(),19+        std::time::SystemTime::now()20+            .duration_since(std::time::UNIX_EPOCH)21+            .map(|d| d.as_nanos())22+            .unwrap_or(0)23+            .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))24+    ));25+    std::fs::create_dir_all(&dir).unwrap();26+    dir27+}28+29+fn git(repo: &Path, args: &[&str]) {30+    let st = Command::new("git")31+        .arg("-C")32+        .arg(repo)33+        .args(args)34+        .status()35+        .expect("spawn git");36+    assert!(st.success(), "git {args:?} failed");37+}38+39+fn write(repo: &Path, rel: &str, body: &str) {40+    let dest = repo.join(rel);41+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();42+    std::fs::write(dest, body).unwrap();43+}44+45+fn init_repo() -> PathBuf {46+    let repo = fresh("repo");47+    git(&repo, &["init", "-b", "main"]);48+    git(&repo, &["config", "user.name", "hu13-test"]);49+    git(&repo, &["config", "user.email", "[email protected]"]);50+    git(&repo, &["config", "commit.gpgsign", "false"]);51+    repo52+}53+54+fn commit(repo: &Path, msg: &str) {55+    git(repo, &["add", "-A"]);56+    git(repo, &["commit", "-qm", msg]);57+}58+59+fn build(repo: &Path, out: &Path) -> std::process::Output {60+    Command::new(env!("CARGO_BIN_EXE_hu13"))61+        .arg("build")62+        .arg(repo)63+        .arg("-o")64+        .arg(out)65+        .output()66+        .expect("spawn hu13 build")67+}68+69+fn read(path: &Path) -> String {70+    std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))71+}72+73+struct Server {74+    child: Child,75+    addr: String,76+}77+78+impl Drop for Server {79+    fn drop(&mut self) {80+        self.child.kill().ok();81+        self.child.wait().ok();82+    }83+}84+85+fn start(www: &Path, inbox: &Path) -> Server {86+    let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));87+    cmd.arg("serve")88+        .arg("--www")89+        .arg(www)90+        .arg("--inbox-dir")91+        .arg(inbox)92+        .arg("--listen")93+        .arg("127.0.0.1:0")94+        .stdout(Stdio::piped())95+        .stderr(Stdio::null());96+    let mut child = cmd.spawn().expect("spawn serve");97+    let stdout = child.stdout.take().expect("stdout");98+    let mut addr = String::new();99+    for line in BufReader::new(stdout).lines().take(50).flatten() {100+        if let Some(rest) = line.split("listening on ").nth(1) {101+            addr = rest.trim().to_string();102+            break;103+        }104+    }105+    assert!(!addr.is_empty(), "serve printed no listening line");106+    Server { child, addr }107+}108+109+fn get(addr: &str, path: &str) -> String {110+    let mut s = TcpStream::connect(addr).expect("connect");111+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();112+    write!(113+        s,114+        "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"115+    )116+    .unwrap();117+    let mut out = Vec::new();118+    s.read_to_end(&mut out).unwrap();119+    String::from_utf8_lossy(&out).into_owned()120+}121+122+#[test]123+fn pages_headers_use_one_splat_for_artifacts() {124+    let repo = init_repo();125+    write(&repo, "README.md", "# headers\n");126+    commit(&repo, "init");127+    let out = fresh("headers-out");128+    let res = build(&repo, &out);129+    assert!(130+        res.status.success(),131+        "{}",132+        String::from_utf8_lossy(&res.stderr)133+    );134+    let headers = read(&out.join("_headers"));135+    assert!(headers.contains("/releases/:version/*"), "{headers}");136+    assert!(!headers.contains("/releases/*/*"), "invalid two-splat rule");137+    let rules: Vec<&str> = headers.lines().filter(|l| l.starts_with('/')).collect();138+    for rule in rules {139+        assert!(140+            rule.matches('*').count() <= 1,141+            "Pages allows one splat per rule: {rule}"142+        );143+    }144+}145+146+#[test]147+fn multibyte_timestamps_do_not_panic() {148+    let repo = init_repo();149+    write(&repo, "README.md", "# timestamps\n");150+    write(151+        &repo,152+        ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",153+        "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: \"2026-01-01 12:34:56+€!\"\n---\nbody\n",154+    );155+    commit(&repo, "init");156+    let out = fresh("ts-out");157+    let res = build(&repo, &out);158+    assert!(159+        res.status.success(),160+        "build panicked? {}",161+        String::from_utf8_lossy(&res.stderr)162+    );163+    let issues = read(&out.join("issues/index.html"));164+    assert!(issues.contains("€"), "timestamp text preserved");165+}166+167+#[cfg(unix)]168+#[test]169+fn symlinked_forge_trees_and_files_are_ignored() {170+    let secret = fresh("secret");171+    write(&secret, "wiki/leak.md", "TOP SECRET WIKI\n");172+    write(173+        &secret,174+        "releases/out/v1/leak.html",175+        "TOP SECRET ARTIFACT\n",176+    );177+    write(&secret, "patch.diff", "TOP SECRET PATCH\n");178+    write(&secret, "steps/00.log", "TOP SECRET LOG\n");179+180+    let repo = init_repo();181+    write(&repo, "README.md", "# symlinks\n");182+    write(183+        &repo,184+        ".forge/releases/v1.md",185+        "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nnotes\n",186+    );187+    write(188+        &repo,189+        ".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",190+        "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: P\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",191+    );192+    write(193+        &repo,194+        ".forge/ci/runs/2026-01-01T00-00-00Z/summary.md",195+        "---\nrun: 2026-01-01T00-00-00Z\nverdict: pass\ncreated: 2026-01-01T00:00:00Z\nsteps: []\n---\nsummary\n",196+    );197+    write(&repo, ".forge/ci/steps/00_test.sh", "true\n");198+    std::fs::create_dir_all(repo.join(".forge/releases/out")).unwrap();199+    std::os::unix::fs::symlink(secret.join("wiki"), repo.join(".forge/wiki")).unwrap();200+    std::os::unix::fs::symlink(201+        secret.join("releases/out/v1"),202+        repo.join(".forge/releases/out/v1"),203+    )204+    .unwrap();205+    std::os::unix::fs::symlink(206+        secret.join("patch.diff"),207+        repo.join(".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff"),208+    )209+    .unwrap();210+    std::fs::create_dir_all(repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps")).unwrap();211+    std::os::unix::fs::symlink(212+        secret.join("steps/00.log"),213+        repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps/00.log"),214+    )215+    .unwrap();216+    commit(&repo, "symlinks");217+218+    let out = fresh("symlink-out");219+    let res = build(&repo, &out);220+    assert!(221+        res.status.success(),222+        "{}",223+        String::from_utf8_lossy(&res.stderr)224+    );225+    let mut hits = Vec::new();226+    for entry in walk(&out) {227+        if let Ok(text) = std::fs::read_to_string(&entry) {228+            if text.contains("TOP SECRET") {229+                hits.push(entry);230+            }231+        }232+    }233+    assert!(hits.is_empty(), "host files published: {hits:?}");234+    assert!(!out.join("wiki/leak.md.html").exists());235+    assert!(!out.join("releases/v1/leak.html").exists());236+}237+238+#[cfg(unix)]239+#[test]240+fn symlinked_forge_root_is_not_read() {241+    let secret = fresh("root-secret");242+    write(&secret, "wiki/leak.md", "TOP SECRET ROOT\n");243+    write(244+        &secret,245+        "releases/v1.md",246+        "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nTOP SECRET ROOT\n",247+    );248+    let repo = init_repo();249+    write(&repo, "README.md", "# root\n");250+    std::os::unix::fs::symlink(&secret, repo.join(".forge")).unwrap();251+    commit(&repo, "root symlink");252+    let out = fresh("root-out");253+    let res = build(&repo, &out);254+    assert!(255+        res.status.success(),256+        "{}",257+        String::from_utf8_lossy(&res.stderr)258+    );259+    for entry in walk(&out) {260+        if let Ok(text) = std::fs::read_to_string(&entry) {261+            assert!(!text.contains("TOP SECRET ROOT"), "{}", entry.display());262+        }263+    }264+}265+266+fn walk(root: &Path) -> Vec<PathBuf> {267+    let mut out = Vec::new();268+    let mut stack = vec![root.to_path_buf()];269+    while let Some(dir) = stack.pop() {270+        let Ok(rd) = std::fs::read_dir(&dir) else {271+            continue;272+        };273+        for ent in rd.flatten() {274+            let p = ent.path();275+            if p.is_dir() {276+                stack.push(p);277+            } else {278+                out.push(p);279+            }280+        }281+    }282+    out283+}284+285+#[test]286+fn reserved_index_names_do_not_hijack_section_indexes() {287+    let repo = init_repo();288+    write(&repo, "README.md", "# indexes\n");289+    write(290+        &repo,291+        ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",292+        "---\nid: index\ntitle: Hijack Issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",293+    );294+    write(295+        &repo,296+        ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",297+        "---\nid: index\ntitle: Hijack Pull\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",298+    );299+    write(300+        &repo,301+        ".forge/releases/index.md",302+        "---\nversion: index\ncreated: 2026-01-01T00:00:00Z\n---\nHijack Release\n",303+    );304+    write(&repo, ".forge/wiki/index", "# Hijack Wiki\n");305+    commit(&repo, "indexes");306+307+    let out = fresh("index-out");308+    let res = build(&repo, &out);309+    assert!(310+        res.status.success(),311+        "{}",312+        String::from_utf8_lossy(&res.stderr)313+    );314+    let stderr = String::from_utf8_lossy(&res.stderr);315+    assert!(stderr.contains("reserved"), "warned: {stderr}");316+    for (rel, marker) in [317+        ("issues/index.html", "Hijack Issue"),318+        ("pulls/index.html", "Hijack Pull"),319+        ("releases/index.html", "Hijack Release"),320+        ("wiki/index.html", "Hijack Wiki"),321+    ] {322+        let page = read(&out.join(rel));323+        assert!(!page.contains(marker), "{rel} hijacked by {marker}");324+    }325+    assert!(read(&out.join("issues/index.html")).contains("Issues"));326+    assert!(read(&out.join("pulls/index.html")).contains("Pulls"));327+    assert!(read(&out.join("releases/index.html")).contains("Releases"));328+    assert!(read(&out.join("wiki/index.html")).contains("Wiki"));329+}330+331+#[test]332+fn markdown_links_images_and_heading_anchors_resolve() {333+    let repo = init_repo();334+    write(335+        &repo,336+        "README.md",337+        "# Root\n\n## Section One\n\n[guide](docs/guide.md#section-one) ![pic](docs/pic.png)\n\n[web](https://example.com)\n",338+    );339+    write(340+        &repo,341+        "docs/guide.md",342+        "# Guide\n\n[peer](peer.md) [root](../README.md) ![pic](pic.png)\n",343+    );344+    write(&repo, "docs/peer.md", "# Peer\n");345+    std::fs::create_dir_all(repo.join("docs")).unwrap();346+    std::fs::write(repo.join("docs/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();347+    write(348+        &repo,349+        ".forge/wiki/home.md",350+        "# Wiki Home\n\n[other](other.md) ![pic](pic.png)\n",351+    );352+    write(&repo, ".forge/wiki/other.md", "# Other\n");353+    std::fs::write(repo.join(".forge/wiki/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();354+    write(355+        &repo,356+        ".forge/releases/v1.md",357+        "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\n# Release\n\n[guide](docs/guide.md) ![pic](docs/pic.png)\n",358+    );359+    write(360+        &repo,361+        ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",362+        "---\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) ![pic](docs/pic.png)\n",363+    );364+    write(365+        &repo,366+        ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",367+        "---\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) ![pic](docs/pic.png)\n",368+    );369+    commit(&repo, "markdown");370+371+    let out = fresh("md-out");372+    let res = build(&repo, &out);373+    assert!(374+        res.status.success(),375+        "{}",376+        String::from_utf8_lossy(&res.stderr)377+    );378+379+    let index = read(&out.join("index.html"));380+    assert!(index.contains("id=\"root\""), "{index}");381+    assert!(index.contains("id=\"section-one\""), "{index}");382+    assert!(383+        index.contains("href=\"/files/docs/guide.md.html#section-one\""),384+        "{index}"385+    );386+    assert!(index.contains("src=\"/assets/docs/pic.png\""), "{index}");387+    assert!(388+        index.contains("href=\"https://example.com\""),389+        "external kept"390+    );391+392+    let guide = read(&out.join("files/docs/guide.md.html"));393+    assert!(guide.contains("id=\"guide\""), "{guide}");394+    assert!(395+        guide.contains("href=\"/files/docs/peer.md.html\""),396+        "{guide}"397+    );398+    assert!(guide.contains("href=\"/files/README.md.html\""), "{guide}");399+    assert!(guide.contains("src=\"/assets/docs/pic.png\""), "{guide}");400+401+    let wiki = read(&out.join("wiki/home.md.html"));402+    assert!(wiki.contains("id=\"wiki-home\""), "{wiki}");403+    assert!(wiki.contains("href=\"/wiki/other.md.html\""), "{wiki}");404+    assert!(405+        wiki.contains("src=\"/assets/.forge/wiki/pic.png\""),406+        "{wiki}"407+    );408+    assert!(out.join("assets/docs/pic.png").is_file());409+    assert!(out.join("assets/.forge/wiki/pic.png").is_file());410+411+    for rel in [412+        "releases/v1.html",413+        "issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.html",414+        "pulls/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.html",415+    ] {416+        let page = read(&out.join(rel));417+        assert!(418+            page.contains("href=\"/files/docs/guide.md.html\""),419+            "{rel}: {page}"420+        );421+        assert!(422+            page.contains("src=\"/assets/docs/pic.png\""),423+            "{rel}: {page}"424+        );425+    }426+}427+428+#[cfg(unix)]429+#[test]430+fn serve_404_headers_and_bundle_symlinks_are_safe() {431+    let secret = fresh("serve-secret");432+    write(&secret, "secret.html", "TOP SECRET 404\n");433+    let www = fresh("serve-www");434+    write(&www, "index.html", "<p>ok</p>\n");435+    std::os::unix::fs::symlink(secret.join("secret.html"), www.join("404.html")).unwrap();436+437+    let src = fresh("bundle-src");438+    git(&src, &["init", "-b", "main"]);439+    git(&src, &["config", "user.name", "hu13-test"]);440+    git(&src, &["config", "user.email", "[email protected]"]);441+    write(&src, "a.txt", "secret\n");442+    commit(&src, "init");443+    let bundle = fresh("private-bundle");444+    git(445+        &src,446+        &[447+            "bundle",448+            "create",449+            bundle.join("repo.bundle").to_str().unwrap(),450+            "main",451+        ],452+    );453+    std::os::unix::fs::symlink(bundle.join("repo.bundle"), www.join("repo.bundle")).unwrap();454+455+    let inbox = fresh("serve-inbox");456+    let srv = start(&www, &inbox);457+    std::thread::sleep(Duration::from_millis(100));458+459+    let root = get(&srv.addr, "/");460+    assert!(root.starts_with("HTTP/1.1 200"), "{root}");461+    assert!(462+        root.contains("Referrer-Policy: strict-origin-when-cross-origin"),463+        "{root}"464+    );465+466+    let missing = get(&srv.addr, "/nope");467+    assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");468+    assert!(!missing.contains("TOP SECRET 404"), "{missing}");469+    assert!(470+        missing.contains("Referrer-Policy: strict-origin-when-cross-origin"),471+        "{missing}"472+    );473+474+    let refs = get(&srv.addr, "/repo.bundle/info/refs?service=git-upload-pack");475+    assert!(refs.starts_with("HTTP/1.1 404"), "{refs}");476+    assert!(!refs.contains("git-upload-pack-advertisement"), "{refs}");477+}