tests/review5_hardening.rs 15.3 KiB 477 lines Raw
1 //! Review #5 hardening (f0rge #106–#117): one-splat Pages artifact2 //! headers, symlink-safe `.forge` reads, multibyte timestamps, reserved3 //! indexes, context-aware markdown links/images/anchors, and `serve`4 //! header/404/bundle parity.5 6 use std::io::{BufRead, BufReader, Read, Write};7 use std::net::TcpStream;8 use std::path::{Path, PathBuf};9 use std::process::{Child, Command, Stdio};10 use std::sync::atomic::{AtomicU64, Ordering};11 use std::time::Duration;12 13 static SEQ: AtomicU64 = AtomicU64::new(0);14 15 fn fresh(prefix: &str) -> PathBuf {16     let dir = std::env::temp_dir().join(format!(17         "hu13-review5-{prefix}-{}-{}",18         std::process::id(),19         std::time::SystemTime::now()20             .duration_since(std::time::UNIX_EPOCH)21             .map(|d| d.as_nanos())22             .unwrap_or(0)23             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))24     ));25     std::fs::create_dir_all(&dir).unwrap();26     dir27 }28 29 fn git(repo: &Path, args: &[&str]) {30     let st = Command::new("git")31         .arg("-C")32         .arg(repo)33         .args(args)34         .status()35         .expect("spawn git");36     assert!(st.success(), "git {args:?} failed");37 }38 39 fn write(repo: &Path, rel: &str, body: &str) {40     let dest = repo.join(rel);41     std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();42     std::fs::write(dest, body).unwrap();43 }44 45 fn init_repo() -> PathBuf {46     let repo = fresh("repo");47     git(&repo, &["init", "-b", "main"]);48     git(&repo, &["config", "user.name", "hu13-test"]);49     git(&repo, &["config", "user.email", "[email protected]"]);50     git(&repo, &["config", "commit.gpgsign", "false"]);51     repo52 }53 54 fn commit(repo: &Path, msg: &str) {55     git(repo, &["add", "-A"]);56     git(repo, &["commit", "-qm", msg]);57 }58 59 fn build(repo: &Path, out: &Path) -> std::process::Output {60     Command::new(env!("CARGO_BIN_EXE_hu13"))61         .arg("build")62         .arg(repo)63         .arg("-o")64         .arg(out)65         .output()66         .expect("spawn hu13 build")67 }68 69 fn read(path: &Path) -> String {70     std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))71 }72 73 struct Server {74     child: Child,75     addr: String,76 }77 78 impl Drop for Server {79     fn drop(&mut self) {80         self.child.kill().ok();81         self.child.wait().ok();82     }83 }84 85 fn start(www: &Path, inbox: &Path) -> Server {86     let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));87     cmd.arg("serve")88         .arg("--www")89         .arg(www)90         .arg("--inbox-dir")91         .arg(inbox)92         .arg("--listen")93         .arg("127.0.0.1:0")94         .stdout(Stdio::piped())95         .stderr(Stdio::null());96     let mut child = cmd.spawn().expect("spawn serve");97     let stdout = child.stdout.take().expect("stdout");98     let mut addr = String::new();99     for line in BufReader::new(stdout).lines().take(50).flatten() {100         if let Some(rest) = line.split("listening on ").nth(1) {101             addr = rest.trim().to_string();102             break;103         }104     }105     assert!(!addr.is_empty(), "serve printed no listening line");106     Server { child, addr }107 }108 109 fn get(addr: &str, path: &str) -> String {110     let mut s = TcpStream::connect(addr).expect("connect");111     s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();112     write!(113         s,114         "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"115     )116     .unwrap();117     let mut out = Vec::new();118     s.read_to_end(&mut out).unwrap();119     String::from_utf8_lossy(&out).into_owned()120 }121 122 #[test]123 fn pages_headers_use_one_splat_for_artifacts() {124     let repo = init_repo();125     write(&repo, "README.md", "# headers\n");126     commit(&repo, "init");127     let out = fresh("headers-out");128     let res = build(&repo, &out);129     assert!(130         res.status.success(),131         "{}",132         String::from_utf8_lossy(&res.stderr)133     );134     let headers = read(&out.join("_headers"));135     assert!(headers.contains("/releases/:version/*"), "{headers}");136     assert!(!headers.contains("/releases/*/*"), "invalid two-splat rule");137     let rules: Vec<&str> = headers.lines().filter(|l| l.starts_with('/')).collect();138     for rule in rules {139         assert!(140             rule.matches('*').count() <= 1,141             "Pages allows one splat per rule: {rule}"142         );143     }144 }145 146 #[test]147 fn multibyte_timestamps_do_not_panic() {148     let repo = init_repo();149     write(&repo, "README.md", "# timestamps\n");150     write(151         &repo,152         ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",153         "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: \"2026-01-01 12:34:56+€!\"\n---\nbody\n",154     );155     commit(&repo, "init");156     let out = fresh("ts-out");157     let res = build(&repo, &out);158     assert!(159         res.status.success(),160         "build panicked? {}",161         String::from_utf8_lossy(&res.stderr)162     );163     let issues = read(&out.join("issues/index.html"));164     assert!(issues.contains("€"), "timestamp text preserved");165 }166 167 #[cfg(unix)]168 #[test]169 fn symlinked_forge_trees_and_files_are_ignored() {170     let secret = fresh("secret");171     write(&secret, "wiki/leak.md", "TOP SECRET WIKI\n");172     write(173         &secret,174         "releases/out/v1/leak.html",175         "TOP SECRET ARTIFACT\n",176     );177     write(&secret, "patch.diff", "TOP SECRET PATCH\n");178     write(&secret, "steps/00.log", "TOP SECRET LOG\n");179 180     let repo = init_repo();181     write(&repo, "README.md", "# symlinks\n");182     write(183         &repo,184         ".forge/releases/v1.md",185         "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nnotes\n",186     );187     write(188         &repo,189         ".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",190         "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: P\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",191     );192     write(193         &repo,194         ".forge/ci/runs/2026-01-01T00-00-00Z/summary.md",195         "---\nrun: 2026-01-01T00-00-00Z\nverdict: pass\ncreated: 2026-01-01T00:00:00Z\nsteps: []\n---\nsummary\n",196     );197     write(&repo, ".forge/ci/steps/00_test.sh", "true\n");198     std::fs::create_dir_all(repo.join(".forge/releases/out")).unwrap();199     std::os::unix::fs::symlink(secret.join("wiki"), repo.join(".forge/wiki")).unwrap();200     std::os::unix::fs::symlink(201         secret.join("releases/out/v1"),202         repo.join(".forge/releases/out/v1"),203     )204     .unwrap();205     std::os::unix::fs::symlink(206         secret.join("patch.diff"),207         repo.join(".forge/prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff"),208     )209     .unwrap();210     std::fs::create_dir_all(repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps")).unwrap();211     std::os::unix::fs::symlink(212         secret.join("steps/00.log"),213         repo.join(".forge/ci/runs/2026-01-01T00-00-00Z/steps/00.log"),214     )215     .unwrap();216     commit(&repo, "symlinks");217 218     let out = fresh("symlink-out");219     let res = build(&repo, &out);220     assert!(221         res.status.success(),222         "{}",223         String::from_utf8_lossy(&res.stderr)224     );225     let mut hits = Vec::new();226     for entry in walk(&out) {227         if let Ok(text) = std::fs::read_to_string(&entry) {228             if text.contains("TOP SECRET") {229                 hits.push(entry);230             }231         }232     }233     assert!(hits.is_empty(), "host files published: {hits:?}");234     assert!(!out.join("wiki/leak.md.html").exists());235     assert!(!out.join("releases/v1/leak.html").exists());236 }237 238 #[cfg(unix)]239 #[test]240 fn symlinked_forge_root_is_not_read() {241     let secret = fresh("root-secret");242     write(&secret, "wiki/leak.md", "TOP SECRET ROOT\n");243     write(244         &secret,245         "releases/v1.md",246         "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\nTOP SECRET ROOT\n",247     );248     let repo = init_repo();249     write(&repo, "README.md", "# root\n");250     std::os::unix::fs::symlink(&secret, repo.join(".forge")).unwrap();251     commit(&repo, "root symlink");252     let out = fresh("root-out");253     let res = build(&repo, &out);254     assert!(255         res.status.success(),256         "{}",257         String::from_utf8_lossy(&res.stderr)258     );259     for entry in walk(&out) {260         if let Ok(text) = std::fs::read_to_string(&entry) {261             assert!(!text.contains("TOP SECRET ROOT"), "{}", entry.display());262         }263     }264 }265 266 fn walk(root: &Path) -> Vec<PathBuf> {267     let mut out = Vec::new();268     let mut stack = vec![root.to_path_buf()];269     while let Some(dir) = stack.pop() {270         let Ok(rd) = std::fs::read_dir(&dir) else {271             continue;272         };273         for ent in rd.flatten() {274             let p = ent.path();275             if p.is_dir() {276                 stack.push(p);277             } else {278                 out.push(p);279             }280         }281     }282     out283 }284 285 #[test]286 fn reserved_index_names_do_not_hijack_section_indexes() {287     let repo = init_repo();288     write(&repo, "README.md", "# indexes\n");289     write(290         &repo,291         ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",292         "---\nid: index\ntitle: Hijack Issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",293     );294     write(295         &repo,296         ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",297         "---\nid: index\ntitle: Hijack Pull\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n",298     );299     write(300         &repo,301         ".forge/releases/index.md",302         "---\nversion: index\ncreated: 2026-01-01T00:00:00Z\n---\nHijack Release\n",303     );304     write(&repo, ".forge/wiki/index", "# Hijack Wiki\n");305     commit(&repo, "indexes");306 307     let out = fresh("index-out");308     let res = build(&repo, &out);309     assert!(310         res.status.success(),311         "{}",312         String::from_utf8_lossy(&res.stderr)313     );314     let stderr = String::from_utf8_lossy(&res.stderr);315     assert!(stderr.contains("reserved"), "warned: {stderr}");316     for (rel, marker) in [317         ("issues/index.html", "Hijack Issue"),318         ("pulls/index.html", "Hijack Pull"),319         ("releases/index.html", "Hijack Release"),320         ("wiki/index.html", "Hijack Wiki"),321     ] {322         let page = read(&out.join(rel));323         assert!(!page.contains(marker), "{rel} hijacked by {marker}");324     }325     assert!(read(&out.join("issues/index.html")).contains("Issues"));326     assert!(read(&out.join("pulls/index.html")).contains("Pulls"));327     assert!(read(&out.join("releases/index.html")).contains("Releases"));328     assert!(read(&out.join("wiki/index.html")).contains("Wiki"));329 }330 331 #[test]332 fn markdown_links_images_and_heading_anchors_resolve() {333     let repo = init_repo();334     write(335         &repo,336         "README.md",337         "# Root\n\n## Section One\n\n[guide](docs/guide.md#section-one) ![pic](docs/pic.png)\n\n[web](https://example.com)\n",338     );339     write(340         &repo,341         "docs/guide.md",342         "# Guide\n\n[peer](peer.md) [root](../README.md) ![pic](pic.png)\n",343     );344     write(&repo, "docs/peer.md", "# Peer\n");345     std::fs::create_dir_all(repo.join("docs")).unwrap();346     std::fs::write(repo.join("docs/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();347     write(348         &repo,349         ".forge/wiki/home.md",350         "# Wiki Home\n\n[other](other.md) ![pic](pic.png)\n",351     );352     write(&repo, ".forge/wiki/other.md", "# Other\n");353     std::fs::write(repo.join(".forge/wiki/pic.png"), b"\x89PNG\r\n\x1a\n").unwrap();354     write(355         &repo,356         ".forge/releases/v1.md",357         "---\nversion: v1\ncreated: 2026-01-01T00:00:00Z\n---\n# Release\n\n[guide](docs/guide.md) ![pic](docs/pic.png)\n",358     );359     write(360         &repo,361         ".forge/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/issue.md",362         "---\nid: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\ntitle: Link issue\nstate: open\ncreated: 2026-01-01T00:00:00Z\n---\n# Issue\n\n[guide](docs/guide.md) ![pic](docs/pic.png)\n",363     );364     write(365         &repo,366         ".forge/prs/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/pr.md",367         "---\nid: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\ntitle: Link PR\nstate: open\nbase: main\nhead: topic\ncreated: 2026-01-01T00:00:00Z\n---\n# PR\n\n[guide](docs/guide.md) ![pic](docs/pic.png)\n",368     );369     commit(&repo, "markdown");370 371     let out = fresh("md-out");372     let res = build(&repo, &out);373     assert!(374         res.status.success(),375         "{}",376         String::from_utf8_lossy(&res.stderr)377     );378 379     let index = read(&out.join("index.html"));380     assert!(index.contains("id=\"root\""), "{index}");381     assert!(index.contains("id=\"section-one\""), "{index}");382     assert!(383         index.contains("href=\"/files/docs/guide.md.html#section-one\""),384         "{index}"385     );386     assert!(index.contains("src=\"/assets/docs/pic.png\""), "{index}");387     assert!(388         index.contains("href=\"https://example.com\""),389         "external kept"390     );391 392     let guide = read(&out.join("files/docs/guide.md.html"));393     assert!(guide.contains("id=\"guide\""), "{guide}");394     assert!(395         guide.contains("href=\"/files/docs/peer.md.html\""),396         "{guide}"397     );398     assert!(guide.contains("href=\"/files/README.md.html\""), "{guide}");399     assert!(guide.contains("src=\"/assets/docs/pic.png\""), "{guide}");400 401     let wiki = read(&out.join("wiki/home.md.html"));402     assert!(wiki.contains("id=\"wiki-home\""), "{wiki}");403     assert!(wiki.contains("href=\"/wiki/other.md.html\""), "{wiki}");404     assert!(405         wiki.contains("src=\"/assets/.forge/wiki/pic.png\""),406         "{wiki}"407     );408     assert!(out.join("assets/docs/pic.png").is_file());409     assert!(out.join("assets/.forge/wiki/pic.png").is_file());410 411     for rel in [412         "releases/v1.html",413         "issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.html",414         "pulls/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.html",415     ] {416         let page = read(&out.join(rel));417         assert!(418             page.contains("href=\"/files/docs/guide.md.html\""),419             "{rel}: {page}"420         );421         assert!(422             page.contains("src=\"/assets/docs/pic.png\""),423             "{rel}: {page}"424         );425     }426 }427 428 #[cfg(unix)]429 #[test]430 fn serve_404_headers_and_bundle_symlinks_are_safe() {431     let secret = fresh("serve-secret");432     write(&secret, "secret.html", "TOP SECRET 404\n");433     let www = fresh("serve-www");434     write(&www, "index.html", "<p>ok</p>\n");435     std::os::unix::fs::symlink(secret.join("secret.html"), www.join("404.html")).unwrap();436 437     let src = fresh("bundle-src");438     git(&src, &["init", "-b", "main"]);439     git(&src, &["config", "user.name", "hu13-test"]);440     git(&src, &["config", "user.email", "[email protected]"]);441     write(&src, "a.txt", "secret\n");442     commit(&src, "init");443     let bundle = fresh("private-bundle");444     git(445         &src,446         &[447             "bundle",448             "create",449             bundle.join("repo.bundle").to_str().unwrap(),450             "main",451         ],452     );453     std::os::unix::fs::symlink(bundle.join("repo.bundle"), www.join("repo.bundle")).unwrap();454 455     let inbox = fresh("serve-inbox");456     let srv = start(&www, &inbox);457     std::thread::sleep(Duration::from_millis(100));458 459     let root = get(&srv.addr, "/");460     assert!(root.starts_with("HTTP/1.1 200"), "{root}");461     assert!(462         root.contains("Referrer-Policy: strict-origin-when-cross-origin"),463         "{root}"464     );465 466     let missing = get(&srv.addr, "/nope");467     assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");468     assert!(!missing.contains("TOP SECRET 404"), "{missing}");469     assert!(470         missing.contains("Referrer-Policy: strict-origin-when-cross-origin"),471         "{missing}"472     );473 474     let refs = get(&srv.addr, "/repo.bundle/info/refs?service=git-upload-pack");475     assert!(refs.starts_with("HTTP/1.1 404"), "{refs}");476     assert!(!refs.contains("git-upload-pack-advertisement"), "{refs}");477 }