1 From 2749d01e0fde0b0d6842704eff4ec0c77ae77d99 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 15:35:29 +00004 Subject: [PATCH] start every build from an empty out/ (#67)5 6 Pages for deleted issues/files kept being served when -o was reused.7 prepare_out() removes the output dir first, refuses to delete the8 repo or any ancestor of it (-o ., -o ..), and removes a symlinked9 output with remove_file (never follows it). Branch resolution runs10 before the clean so a bad --branch does not destroy the old build.11 ---12 src/build.rs | 50 +++++++++++++++++++13 tests/clean_out.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++14 2 files changed, 169 insertions(+)15 create mode 100644 tests/clean_out.rs16 17 diff --git a/src/build.rs b/src/build.rs18 index 5ca0e2a..90e6651 10064419 --- a/src/build.rs20 +++ b/src/build.rs21 @@ -843,11 +843,61 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> {22 Ok(())23 }24 25 +/// Absolute lexical form of `p` (no symlink resolution beyond what the26 +/// OS already did for existing components).27 +fn absolute(p: &Path) -> std::path::PathBuf {28 + if p.is_absolute() {29 + p.to_path_buf()30 + } else {31 + std::env::current_dir()32 + .map(|cwd| cwd.join(p))33 + .unwrap_or_else(|_| p.to_path_buf())34 + }35 +}36 +37 +/// Remove everything already in `out/` before a build (f0rge #67).38 +///39 +/// Without this, pages for deleted issues/files keep being served from a40 +/// reused output dir. Refuses to delete the repo itself or anything that41 +/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir42 +/// without following it.43 +fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> {44 + let out_abs = absolute(out);45 + let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo));46 + if out_abs == repo_abs || repo_abs.starts_with(&out_abs) {47 + return Err(format!(48 + "hu13: refusing to clean output dir {}: it contains the repo",49 + out.display()50 + ));51 + }52 + if out_abs.parent().is_none() {53 + return Err("hu13: refusing to use the filesystem root as output dir".to_string());54 + }55 + match std::fs::symlink_metadata(&out_abs) {56 + Ok(meta) if meta.file_type().is_symlink() => {57 + std::fs::remove_file(&out_abs)58 + .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?;59 + }60 + Ok(meta) if meta.is_dir() => {61 + std::fs::remove_dir_all(&out_abs)62 + .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?;63 + }64 + Ok(_) => {65 + std::fs::remove_file(&out_abs)66 + .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?;67 + }68 + Err(_) => {}69 + }70 + std::fs::create_dir_all(&out_abs)71 + .map_err(|e| format!("hu13: create output dir {}: {e}", out.display()))72 +}73 +74 /// Build the full static site. Returns a summary on success.75 pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSummary, String> {76 let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref())77 .map_err(|e| format!("hu13: {e}"))?;78 let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;79 + prepare_out(repo, out)?;80 let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());81 let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());82 83 diff --git a/tests/clean_out.rs b/tests/clean_out.rs84 new file mode 10064485 index 0000000..5288f5686 --- /dev/null87 +++ b/tests/clean_out.rs88 @@ -0,0 +1,119 @@89 +//! f0rge #67: a reused `-o DIR` must not keep serving pages whose source90 +//! (issue, file, release) was deleted. Every build starts from an empty91 +//! output dir, and the guard refuses to delete the repo or its ancestors.92 +93 +use std::path::{Path, PathBuf};94 +use std::process::Command;95 +96 +fn git(repo: &Path, args: &[&str]) {97 + let st = Command::new("git")98 + .arg("-C")99 + .arg(repo)100 + .args(args)101 + .status()102 + .expect("spawn git");103 + assert!(st.success(), "git {args:?} failed");104 +}105 +106 +fn fresh_dir(prefix: &str) -> PathBuf {107 + let dir = std::env::temp_dir().join(format!(108 + "hu13-clean-{prefix}-{}-{}",109 + std::process::id(),110 + std::time::SystemTime::now()111 + .duration_since(std::time::UNIX_EPOCH)112 + .map(|d| d.as_nanos())113 + .unwrap_or(0)114 + ));115 + std::fs::create_dir_all(&dir).unwrap();116 + dir117 +}118 +119 +fn build(repo: &Path, out: &Path) -> std::process::Output {120 + Command::new(env!("CARGO_BIN_EXE_hu13"))121 + .arg("build")122 + .arg(repo)123 + .arg("-o")124 + .arg(out)125 + .output()126 + .expect("spawn hu13 build")127 +}128 +129 +#[test]130 +fn rebuild_removes_stale_pages() {131 + let repo = fresh_dir("repo");132 + let out = fresh_dir("out");133 + git(&repo, &["init", "-b", "main"]);134 + git(&repo, &["config", "user.name", "hu13-test"]);135 + git(&repo, &["config", "user.email", "[email protected]"]);136 + git(&repo, &["config", "commit.gpgsign", "false"]);137 + std::fs::write(repo.join("README.md"), "# clean\n").unwrap();138 + std::fs::write(repo.join("keep.txt"), "keep\n").unwrap();139 + let issue = repo.join(".forge/issues/99999999-9999-4999-8999-999999999999");140 + std::fs::create_dir_all(&issue).unwrap();141 + std::fs::write(142 + issue.join("issue.md"),143 + "---\nid: 99999999-9999-4999-8999-999999999999\ntitle: gone soon\n\144 + state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",145 + )146 + .unwrap();147 + git(&repo, &["add", "-A"]);148 + git(&repo, &["commit", "-m", "one"]);149 +150 + let first = build(&repo, &out);151 + assert!(152 + first.status.success(),153 + "first build: {}",154 + String::from_utf8_lossy(&first.stderr)155 + );156 + let page = out.join("issues/99999999-9999-4999-8999-999999999999.html");157 + assert!(page.is_file(), "issue page written");158 + assert!(out.join("raw/keep.txt").is_file());159 +160 + // Delete the issue + a file, rebuild into the same out dir.161 + std::fs::remove_dir_all(repo.join(".forge/issues/99999999-9999-4999-8999-999999999999"))162 + .unwrap();163 + std::fs::remove_file(repo.join("keep.txt")).unwrap();164 + std::fs::write(repo.join("README.md"), "# clean two\n").unwrap();165 + git(&repo, &["add", "-A"]);166 + git(&repo, &["commit", "-m", "two"]);167 +168 + let second = build(&repo, &out);169 + assert!(170 + second.status.success(),171 + "second build: {}",172 + String::from_utf8_lossy(&second.stderr)173 + );174 + assert!(!page.exists(), "stale issue page must be gone");175 + assert!(176 + !out.join("raw/keep.txt").exists(),177 + "stale raw file must be gone"178 + );179 + assert!(out.join("raw/README.md").is_file(), "fresh output present");180 +}181 +182 +#[test]183 +fn refuses_to_clean_repo_or_its_parent() {184 + let repo = fresh_dir("repo");185 + git(&repo, &["init", "-b", "main"]);186 + git(&repo, &["config", "user.name", "hu13-test"]);187 + git(&repo, &["config", "user.email", "[email protected]"]);188 + git(&repo, &["config", "commit.gpgsign", "false"]);189 + std::fs::write(repo.join("README.md"), "# keep\n").unwrap();190 + git(&repo, &["add", "-A"]);191 + git(&repo, &["commit", "-m", "one"]);192 + let sender = repo.join("src.txt");193 + std::fs::write(&sender, "must survive\n").unwrap();194 +195 + for out in [repo.clone(), repo.parent().unwrap().to_path_buf()] {196 + let res = build(&repo, &out);197 + assert!(198 + !res.status.success(),199 + "must refuse to clean {}",200 + out.display()201 + );202 + let err = String::from_utf8_lossy(&res.stderr);203 + assert!(err.contains("refusing"), "stderr: {err}");204 + assert!(repo.join("README.md").is_file(), "repo untouched");205 + assert!(sender.is_file(), "repo untouched");206 + }207 +}208 -- 209 2.56.0210