From 2749d01e0fde0b0d6842704eff4ec0c77ae77d99 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 15:35:29 +0000 Subject: [PATCH] start every build from an empty out/ (#67) Pages for deleted issues/files kept being served when -o was reused. prepare_out() removes the output dir first, refuses to delete the repo or any ancestor of it (-o ., -o ..), and removes a symlinked output with remove_file (never follows it). Branch resolution runs before the clean so a bad --branch does not destroy the old build. --- src/build.rs | 50 +++++++++++++++++++ tests/clean_out.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/clean_out.rs diff --git a/src/build.rs b/src/build.rs index 5ca0e2a..90e6651 100644 --- a/src/build.rs +++ b/src/build.rs @@ -843,11 +843,61 @@ fn write_favicon_and_manifest(out: &Path) -> Result<(), String> { Ok(()) } +/// Absolute lexical form of `p` (no symlink resolution beyond what the +/// OS already did for existing components). +fn absolute(p: &Path) -> std::path::PathBuf { + if p.is_absolute() { + p.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(p)) + .unwrap_or_else(|_| p.to_path_buf()) + } +} + +/// Remove everything already in `out/` before a build (f0rge #67). +/// +/// Without this, pages for deleted issues/files keep being served from a +/// reused output dir. Refuses to delete the repo itself or anything that +/// contains it (`-o ..`, `-o .`), and handles a symlinked output dir +/// without following it. +fn prepare_out(repo: &Path, out: &Path) -> Result<(), String> { + let out_abs = absolute(out); + let repo_abs = repo.canonicalize().unwrap_or_else(|_| absolute(repo)); + if out_abs == repo_abs || repo_abs.starts_with(&out_abs) { + return Err(format!( + "hu13: refusing to clean output dir {}: it contains the repo", + out.display() + )); + } + if out_abs.parent().is_none() { + return Err("hu13: refusing to use the filesystem root as output dir".to_string()); + } + match std::fs::symlink_metadata(&out_abs) { + Ok(meta) if meta.file_type().is_symlink() => { + std::fs::remove_file(&out_abs) + .map_err(|e| format!("hu13: remove symlinked out dir {}: {e}", out.display()))?; + } + Ok(meta) if meta.is_dir() => { + std::fs::remove_dir_all(&out_abs) + .map_err(|e| format!("hu13: clean output dir {}: {e}", out.display()))?; + } + Ok(_) => { + std::fs::remove_file(&out_abs) + .map_err(|e| format!("hu13: remove output file {}: {e}", out.display()))?; + } + Err(_) => {} + } + std::fs::create_dir_all(&out_abs) + .map_err(|e| format!("hu13: create output dir {}: {e}", out.display())) +} + /// Build the full static site. Returns a summary on success. pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result { let branch = gitx::resolve_branch(repo, opts.branch_flag.as_deref()) .map_err(|e| format!("hu13: {e}"))?; let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?; + prepare_out(repo, out)?; let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref()); let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref()); diff --git a/tests/clean_out.rs b/tests/clean_out.rs new file mode 100644 index 0000000..5288f56 --- /dev/null +++ b/tests/clean_out.rs @@ -0,0 +1,119 @@ +//! f0rge #67: a reused `-o DIR` must not keep serving pages whose source +//! (issue, file, release) was deleted. Every build starts from an empty +//! output dir, and the guard refuses to delete the repo or its ancestors. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +fn fresh_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-clean-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn build(repo: &Path, out: &Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("build") + .arg(repo) + .arg("-o") + .arg(out) + .output() + .expect("spawn hu13 build") +} + +#[test] +fn rebuild_removes_stale_pages() { + let repo = fresh_dir("repo"); + let out = fresh_dir("out"); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.join("README.md"), "# clean\n").unwrap(); + std::fs::write(repo.join("keep.txt"), "keep\n").unwrap(); + let issue = repo.join(".forge/issues/99999999-9999-4999-8999-999999999999"); + std::fs::create_dir_all(&issue).unwrap(); + std::fs::write( + issue.join("issue.md"), + "---\nid: 99999999-9999-4999-8999-999999999999\ntitle: gone soon\n\ + state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n", + ) + .unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + + let first = build(&repo, &out); + assert!( + first.status.success(), + "first build: {}", + String::from_utf8_lossy(&first.stderr) + ); + let page = out.join("issues/99999999-9999-4999-8999-999999999999.html"); + assert!(page.is_file(), "issue page written"); + assert!(out.join("raw/keep.txt").is_file()); + + // Delete the issue + a file, rebuild into the same out dir. + std::fs::remove_dir_all(repo.join(".forge/issues/99999999-9999-4999-8999-999999999999")) + .unwrap(); + std::fs::remove_file(repo.join("keep.txt")).unwrap(); + std::fs::write(repo.join("README.md"), "# clean two\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "two"]); + + let second = build(&repo, &out); + assert!( + second.status.success(), + "second build: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert!(!page.exists(), "stale issue page must be gone"); + assert!( + !out.join("raw/keep.txt").exists(), + "stale raw file must be gone" + ); + assert!(out.join("raw/README.md").is_file(), "fresh output present"); +} + +#[test] +fn refuses_to_clean_repo_or_its_parent() { + let repo = fresh_dir("repo"); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + std::fs::write(repo.join("README.md"), "# keep\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let sender = repo.join("src.txt"); + std::fs::write(&sender, "must survive\n").unwrap(); + + for out in [repo.clone(), repo.parent().unwrap().to_path_buf()] { + let res = build(&repo, &out); + assert!( + !res.status.success(), + "must refuse to clean {}", + out.display() + ); + let err = String::from_utf8_lossy(&res.stderr); + assert!(err.contains("refusing"), "stderr: {err}"); + assert!(repo.join("README.md").is_file(), "repo untouched"); + assert!(sender.is_file(), "repo untouched"); + } +} -- 2.56.0