record #61 merge

e834a46gl0w

all commits

Message

record #61 merge

Diff

.forge/merges/2026-10-27T19-51-08.243072797Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T19-51-08.243072797Z.md b/.forge/merges/2026-10-27T19-51-08.243072797Z.mdnew file mode 100644index 0000000..82634b0--- /dev/null+++ b/.forge/merges/2026-10-27T19-51-08.243072797Z.md@@ -0,0 +1,9 @@1+---2+pr: 3df93c3a-bd5e-43f4-bb73-0f3dd886eeb13+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T19:51:08.243072797Z7+---8+clean apply9+patch: ae66bc88a5c4a7da3b47526612b840522af21e40
.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/patch.diff+1473 −0
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 `&amp;` 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("&lt;script&gt;"), "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) ![pic](docs/pic.png)\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) ![pic](pic.png)\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) ![pic](pic.png)\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) ![pic](docs/pic.png)\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) ![pic](docs/pic.png)\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) ![pic](docs/pic.png)\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+
.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/pr.md+12 −0
diff --git a/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/pr.md b/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/pr.mdnew file mode 100644index 0000000..ae68620--- /dev/null+++ b/.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/pr.md@@ -0,0 +1,12 @@1+---2+id: 3df93c3a-bd5e-43f4-bb73-0f3dd886eeb13+title: 'review #5: harden Pages headers, .forge symlinks, links, origin/static parity (#106-#117)'4+state: merged5+base: master6+head: fix/review5-hardening7+draft: false8+issues: []9+ci: pass10+created: 2026-10-27T19:50:01.170634788Z11+updated: 2026-10-27T19:51:08.243072797Z12+---