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 }