1 //! f0rge #67: a reused `-o DIR` must not keep serving pages whose source2 //! (issue, file, release) was deleted. Every build starts from an empty3 //! output dir, and the guard refuses to delete the repo or its ancestors.4 5 use std::sync::atomic::{AtomicU64, Ordering};6 7 static SEQ: AtomicU64 = AtomicU64::new(0);8 9 use std::path::{Path, PathBuf};10 use std::process::Command;11 12 fn git(repo: &Path, args: &[&str]) {13 let st = Command::new("git")14 .arg("-C")15 .arg(repo)16 .args(args)17 .status()18 .expect("spawn git");19 assert!(st.success(), "git {args:?} failed");20 }21 22 fn fresh_dir(prefix: &str) -> PathBuf {23 let dir = std::env::temp_dir().join(format!(24 "hu13-clean-{prefix}-{}-{}",25 std::process::id(),26 std::time::SystemTime::now()27 .duration_since(std::time::UNIX_EPOCH)28 .map(|d| d.as_nanos())29 .unwrap_or(0)30 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))31 ));32 std::fs::create_dir_all(&dir).unwrap();33 dir34 }35 36 fn build(repo: &Path, out: &Path) -> std::process::Output {37 Command::new(env!("CARGO_BIN_EXE_hu13"))38 .arg("build")39 .arg(repo)40 .arg("-o")41 .arg(out)42 .output()43 .expect("spawn hu13 build")44 }45 46 #[test]47 fn rebuild_removes_stale_pages() {48 let repo = fresh_dir("repo");49 let out = fresh_dir("out");50 git(&repo, &["init", "-b", "main"]);51 git(&repo, &["config", "user.name", "hu13-test"]);52 git(&repo, &["config", "user.email", "[email protected]"]);53 git(&repo, &["config", "commit.gpgsign", "false"]);54 std::fs::write(repo.join("README.md"), "# clean\n").unwrap();55 std::fs::write(repo.join("keep.txt"), "keep\n").unwrap();56 let issue = repo.join(".forge/issues/99999999-9999-4999-8999-999999999999");57 std::fs::create_dir_all(&issue).unwrap();58 std::fs::write(59 issue.join("issue.md"),60 "---\nid: 99999999-9999-4999-8999-999999999999\ntitle: gone soon\n\61 state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",62 )63 .unwrap();64 git(&repo, &["add", "-A"]);65 git(&repo, &["commit", "-m", "one"]);66 67 let first = build(&repo, &out);68 assert!(69 first.status.success(),70 "first build: {}",71 String::from_utf8_lossy(&first.stderr)72 );73 let page = out.join("issues/99999999-9999-4999-8999-999999999999.html");74 assert!(page.is_file(), "issue page written");75 assert!(out.join("raw/keep.txt").is_file());76 77 // Delete the issue + a file, rebuild into the same out dir.78 std::fs::remove_dir_all(repo.join(".forge/issues/99999999-9999-4999-8999-999999999999"))79 .unwrap();80 std::fs::remove_file(repo.join("keep.txt")).unwrap();81 std::fs::write(repo.join("README.md"), "# clean two\n").unwrap();82 git(&repo, &["add", "-A"]);83 git(&repo, &["commit", "-m", "two"]);84 85 let second = build(&repo, &out);86 assert!(87 second.status.success(),88 "second build: {}",89 String::from_utf8_lossy(&second.stderr)90 );91 assert!(!page.exists(), "stale issue page must be gone");92 assert!(93 !out.join("raw/keep.txt").exists(),94 "stale raw file must be gone"95 );96 assert!(out.join("raw/README.md").is_file(), "fresh output present");97 }98 99 #[test]100 fn refuses_to_clean_repo_or_its_parent() {101 let repo = fresh_dir("repo");102 git(&repo, &["init", "-b", "main"]);103 git(&repo, &["config", "user.name", "hu13-test"]);104 git(&repo, &["config", "user.email", "[email protected]"]);105 git(&repo, &["config", "commit.gpgsign", "false"]);106 std::fs::write(repo.join("README.md"), "# keep\n").unwrap();107 git(&repo, &["add", "-A"]);108 git(&repo, &["commit", "-m", "one"]);109 let sender = repo.join("src.txt");110 std::fs::write(&sender, "must survive\n").unwrap();111 112 for out in [repo.clone(), repo.parent().unwrap().to_path_buf()] {113 let res = build(&repo, &out);114 assert!(115 !res.status.success(),116 "must refuse to clean {}",117 out.display()118 );119 let err = String::from_utf8_lossy(&res.stderr);120 assert!(err.contains("refusing"), "stderr: {err}");121 assert!(repo.join("README.md").is_file(), "repo untouched");122 assert!(sender.is_file(), "repo untouched");123 }124 }125 126 #[test]127 fn refuses_relative_dotdot_output_and_keeps_parent() {128 let root = fresh_dir("dotdot");129 let repo = root.join("repo");130 std::fs::create_dir_all(repo.join("src")).unwrap();131 git(&repo, &["init", "-b", "main"]);132 git(&repo, &["config", "user.name", "hu13-test"]);133 git(&repo, &["config", "user.email", "[email protected]"]);134 git(&repo, &["config", "commit.gpgsign", "false"]);135 std::fs::write(repo.join("README.md"), "# keep\n").unwrap();136 std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();137 git(&repo, &["add", "-A"]);138 git(&repo, &["commit", "-m", "one"]);139 let canary = root.join("canary.txt");140 std::fs::write(&canary, "must survive\n").unwrap();141 142 let res = Command::new(env!("CARGO_BIN_EXE_hu13"))143 .arg("build")144 .arg(&repo)145 .arg("-o")146 .arg("..")147 .current_dir(&repo)148 .output()149 .expect("spawn hu13 build");150 assert!(!res.status.success(), "relative .. must be refused");151 let err = String::from_utf8_lossy(&res.stderr);152 assert!(err.contains("refusing"), "stderr: {err}");153 assert!(canary.is_file(), "parent canary must survive");154 assert!(repo.join("README.md").is_file(), "repo must survive");155 }156 157 #[cfg(unix)]158 #[test]159 fn refuses_symlinked_ancestor_into_repo() {160 let root = fresh_dir("symlink");161 let repo = root.join("repo");162 std::fs::create_dir_all(repo.join("src")).unwrap();163 git(&repo, &["init", "-b", "main"]);164 git(&repo, &["config", "user.name", "hu13-test"]);165 git(&repo, &["config", "user.email", "[email protected]"]);166 git(&repo, &["config", "commit.gpgsign", "false"]);167 std::fs::write(repo.join("README.md"), "# keep\n").unwrap();168 std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();169 git(&repo, &["add", "-A"]);170 git(&repo, &["commit", "-m", "one"]);171 let alias = root.join("alias");172 std::os::unix::fs::symlink(&repo, &alias).unwrap();173 174 let res = build(&repo, &alias.join("src"));175 assert!(!res.status.success(), "symlinked src must be refused");176 let err = String::from_utf8_lossy(&res.stderr);177 assert!(err.contains("refusing"), "stderr: {err}");178 assert!(179 repo.join("src/main.rs").is_file(),180 "tracked source must survive"181 );182 }