.forge/prs/3df93c3a-bd5e-43f4-bb73-0f3dd886eeb1/patch.diff 55.0 KiB 1473 lines Raw
1 From ee6e71b2eb403bfa513cf2a3465991004a777c65 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