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