@@ -1,6 +1,305 @@
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Whether a runner's workspaces can never be reused and so must be removed
/// when the job ends, regardless of the configured `cleanup` policy.
///
/// True for `--ephemeral` runners: they deregister after one job and re-register
/// with a fresh `runner_id`, so a workspace keyed on that id is dead the moment
/// the job finishes — nothing will ever key to it again. Left behind under the
/// default `cleanup = "never"`, an ephemeral pool would accumulate one abandoned
/// checkout per job until the disk filled.
///
/// Before this module keyed on the runner id, successive ephemeral runners
/// happened to share a workspace and inherited a warm build cache. That sharing
/// was the same mechanism that let two *concurrent* runners overwrite each
/// other's tree, so it is deliberately gone: a cold build is the price of the
/// isolation, and it is the safe direction to err in.
pub fn is_disposable(ephemeral: bool) -> bool {
ephemeral
}
/// Applies the workspace retention decision on scope exit, so every way out of
/// a job goes through one place.
///
/// `execute_job` can return early after the checkout exists — service startup
/// failure, prepare-image failure — and the end-of-function cleanup never runs
/// on those paths. That leaked a full checkout per failed job for every policy:
/// `always` never fired, and for an ephemeral runner the directory was
/// unreachable the moment the process re-registered under a new id. Tying the
/// decision to `Drop` covers early returns and panics without repeating the call
/// at each `return`.
pub struct WorkspaceGuard {
path: Option<PathBuf>,
policy: String,
disposable: bool,
succeeded: bool,
}
impl WorkspaceGuard {
/// `policy` is the configured `cleanup` value; `disposable` marks a runner
/// whose workspaces can never be reused (see [`is_disposable`]).
pub fn new(path: Option<PathBuf>, policy: &str, disposable: bool) -> Self {
Self {
path,
policy: policy.to_string(),
disposable,
succeeded: false,
}
}
/// Record that the job's steps completed successfully, for `on-success`.
/// Left false on any early return, which is the conservative answer.
pub fn mark_succeeded(&mut self) {
self.succeeded = true;
}
fn should_remove(&self) -> bool {
// A disposable runner's workspace is keyed on an id that dies with this
// job, so keeping it only grows the disk — this deliberately overrides
// `never`. Use `--once` instead of `--ephemeral` to retain a tree for
// post-mortem debugging.
if self.disposable {
return true;
}
match self.policy.as_str() {
"always" => true,
"on-success" => self.succeeded,
_ => false, // "never" — keep for caching
}
}
}
impl Drop for WorkspaceGuard {
fn drop(&mut self) {
if !self.should_remove() {
return;
}
if let Some(ref path) = self.path {
cleanup(path);
}
}
}
/// The per-job workspace directory: `<work_dir>/<owner>/<repo>/<instance>-<slot>`.
///
/// The `<instance>` component is what keeps two runners on one host apart. The
/// path used to be keyed on `(owner, repo, slot)` alone, which is only unique
/// *within* one runner process — `loop_runner` hands each poller a fixed slot in
/// `1..=parallel`. Two runner installations sharing a `work_dir` (which
/// `anvil runner service list` exists to support) both have a slot 1, so their
/// jobs resolved to the same directory and `git checkout --force` rewrote each
/// other's source tree mid-build. The symptom is a compiler error naming a file
/// that is genuinely present in the job's own commit, which reads as a flaky
/// test rather than as the runner serving the wrong tree.
///
/// Keyed on the runner id rather than the job or run id on purpose: the
/// workspace is deliberately reused across jobs to preserve build caches (there
/// is no `git clean` here for the same reason), so it must stay stable for a
/// given runner+slot while differing between runners.
///
/// The exception is an `--ephemeral` runner, which re-registers with a *new* id
/// for every job. Its workspaces are therefore single-use by construction and
/// are deleted when the job ends — see [`is_disposable`].
///
/// Changing this key strands the pre-upgrade `<slot>` directories, and a
/// persistent runner that re-registers strands its previous id's directories the
/// same way. Those are reclaimed by [`prune_workspaces`] rather than adopted:
/// renaming a directory into place would race a runner still executing a job out
/// of it — during a rolling upgrade that peer is running an older build and so
/// participates in no locking protocol — which is precisely the tree-stomping
/// this module exists to eliminate. The cost is that the first job after an
/// upgrade rebuilds from cold.
///
/// This lengthens the final path component from one character to nine. On
/// Windows that eats into the 260-character `MAX_PATH` budget, which deep build
/// trees (`target\debug\build\<crate>-<hash>\out\...`, nested `node_modules`)
/// can already strain; a runner close to the limit may need a shorter
/// `work_dir`.
fn workspace_path(work_dir: &Path, owner: &str, repo: &str, runner_id: &str, slot: u32) -> PathBuf {
work_dir
.join(owner)
.join(repo)
.join(format!("{}-{}", instance_key(runner_id), slot))
}
/// Short, filesystem-safe discriminator for a runner.
///
/// A hash of the whole id rather than a prefix of it: `runner_id` is whatever
/// string the server hands back at registration, so "it's a UUID, a prefix is
/// unique enough" is an assumption about a value this code doesn't own. If ids
/// ever gain a shared prefix (`runner-prod-01` / `runner-prod-02`, or ULIDs
/// whose leading characters are a timestamp), truncation would map two runners
/// to one key and silently restore the cross-runner tree-stomping this module
/// exists to prevent — with no error anywhere. Hashing removes the assumption:
/// distinct ids give distinct keys whatever their shape, and hex output is
/// inherently path-safe.
fn instance_key(runner_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(runner_id.as_bytes());
format!("{:x}", hasher.finalize())[..INSTANCE_KEY_LEN].to_string()
}
/// Hex characters of the runner-id hash used in a workspace name. Also what
/// [`is_workspace_name`] matches on, so the sweep recognises exactly the names
/// [`workspace_path`] produces.
const INSTANCE_KEY_LEN: usize = 8;
/// Reclaim workspace directories that nothing can reach any more.
///
/// Keying on the runner id means a directory becomes unreachable as soon as its
/// runner does: an `--ephemeral` runner re-registers with a new id per job, and
/// a persistent runner gets a new id whenever it re-registers (config lost,
/// token rotated, machine re-provisioned). Pre-upgrade `<slot>` directories from
/// the old layout are unreachable for the same reason. None of those are ever
/// touched again — `cleanup` only removes the path `prepare` just returned — so
/// without a sweep each one leaks its multi-GB build tree permanently. Prepared
/// images have `prune_prepared_images` for exactly this reason; this is the
/// workspace equivalent.
///
/// Age is the only safe signal available. A directory cannot be claimed by
/// inspecting the runner registry, because the peer that might be using it may
/// be a *different* runner process — possibly one running an older build that
/// participates in no locking protocol — so anything short of "untouched for
/// days" risks deleting a live checkout. `max_age_days` is therefore generous,
/// and mtime is taken from the directory's most recently modified entry: a build
/// in progress rewrites files constantly, so an active workspace is never close
/// to the threshold.
pub fn prune_workspaces(work_dir: &Path, max_age_days: u64) {
let max_age = std::time::Duration::from_secs(max_age_days * 24 * 60 * 60);
let now = std::time::SystemTime::now();
// Layout is <work_dir>/<owner>/<repo>/<workspace>; only the leaf is a
// workspace, so descend exactly two levels before considering anything.
for owner in read_dirs(work_dir) {
for repo in read_dirs(&owner) {
for ws in read_dirs(&repo) {
// Only ever delete something this module created. Without this
// the sweep would remove ANY directory three levels down, which
// for a work_dir pointed at a shared mount means the operator's
// data.
if !is_workspace_name(&ws) {
continue;
}
// A live owner beats any timestamp: process liveness needs no
// clock, so this holds even when the host's is wrong.
if let Some(pid) = lock_holder(&ws) {
if crate::platform::is_alive(pid) {
continue;
}
}
let idle = last_activity(&ws)
.and_then(|t| now.duration_since(t).ok())
.map(|d| d > max_age)
.unwrap_or(false);
if !idle {
continue;
}
eprintln!(
"reclaiming workspace unused for over {max_age_days} days: {}",
ws.display()
);
cleanup(&ws);
}
}
}
}
/// Whether `path`'s file name is one this module produces: the current
/// `<8-hex>-<slot>` form, or the pre-instance-scoping all-digits `<slot>`.
fn is_workspace_name(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.chars().all(|c| c.is_ascii_digit()) {
return !name.is_empty();
}
match name.split_once('-') {
Some((key, slot)) => {
key.len() == INSTANCE_KEY_LEN
&& key.chars().all(|c| c.is_ascii_hexdigit())
&& !slot.is_empty()
&& slot.chars().all(|c| c.is_ascii_digit())
}
None => false,
}
}
/// Name of the file recording which process currently owns a workspace.
const LOCK_FILE: &str = ".anvil-workspace-owner";
/// Record this process as the workspace's owner. Best-effort: a failure only
/// costs the sweep its liveness signal, which then falls back to age alone.
fn write_lock(ws: &Path) {
let _ = std::fs::write(ws.join(LOCK_FILE), std::process::id().to_string());
}
/// The PID recorded in a workspace's lock file, if it holds one.
fn lock_holder(ws: &Path) -> Option<i32> {
std::fs::read_to_string(ws.join(LOCK_FILE))
.ok()?
.trim()
.parse()
.ok()
}
/// Remove a directory's contents, leaving the directory itself. Best-effort:
/// anything that survives (e.g. root-owned build output) is reported by the
/// caller's clone failing, not swallowed.
fn clear_dir_contents(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let removed = match entry.file_type() {
Ok(ft) if ft.is_dir() => std::fs::remove_dir_all(&path),
_ => std::fs::remove_file(&path),
};
if let Err(e) = removed {
eprintln!("warning: could not clear {}: {e}", path.display());
}
}
}
/// Immediate subdirectories of `dir`, or empty if it can't be read.
///
/// Symlinks are skipped: `is_dir()` follows them, so a symlinked owner or repo
/// level would let the sweep walk out of `work_dir` entirely and delete data on
/// a volume the operator never pointed the runner at.
fn read_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|e| {
std::fs::symlink_metadata(e.path())
.map(|m| m.file_type().is_dir())
.unwrap_or(false)
})
.map(|e| e.path())
.collect()
}
/// Most recent mtime among a workspace's immediate entries, falling back to the
/// directory's own. Checking the entries matters because writing a file updates
/// the file's mtime and its parent's only on create/delete — a long incremental
/// build can leave the top-level directory's own mtime stale.
fn last_activity(ws: &Path) -> Option<std::time::SystemTime> {
let own = std::fs::metadata(ws).and_then(|m| m.modified()).ok();
let newest_child = std::fs::read_dir(ws)
.ok()?
.flatten()
.filter_map(|e| e.metadata().ok()?.modified().ok())
.max();
match (own, newest_child) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
}
}
/// Prepare workspace for a job: clone or fetch, then checkout the target SHA.
/// Returns the workspace directory path.
pub fn prepare(
@@ -8,34 +307,47 @@
repo_clone_url: &str,
commit_sha: &str,
slot: u32,
runner_id: &str,
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let (owner, repo) = parse_repo_url(repo_clone_url)?;
// Localize URL for Docker: host.docker.internal → 127.0.0.1
let local_url = repo_clone_url.replace("host.docker.internal", "127.0.0.1");
let workspace = workspace_path(work_dir, &owner, &repo, runner_id, slot);
let workspace = work_dir.join(&owner).join(&repo).join(slot.to_string());
std::fs::create_dir_all(&workspace)?;
// Claim the workspace for this process before touching it, so a concurrent
// sweep can tell it is in use no matter what the clock says.
write_lock(&workspace);
let git_dir = workspace.join(".git");
if git_dir.exists() {
// Existing checkout — update remote and fetch. Both are survivable on
// their own: if the commit is already local the checkout still succeeds,
// Existing checkout — update remote and fetch
run_git(
// and if it isn't, the checkout below reports it.
run_git_lenient(
&workspace,
&["remote", "set-url", "origin", &local_url],
"set-url",
);
)?;
run_git(&workspace, &["fetch", "origin", "--prune"], "fetch")?;
run_git_lenient(&workspace, &["fetch", "origin", "--prune"], "fetch");
} else {
// No `.git`, but the directory may still hold debris — a previous
// cleanup that failed partway (build output written as root by the job's
// Fresh clone
run_git_in(
// container is not removable by a non-root runner) leaves exactly this
// state. `git clone` refuses a non-empty target, so clear it first;
// whatever survives makes the clone fail loudly below rather than
// silently building the wrong tree.
clear_dir_contents(&workspace);
run_git_checked(
work_dir,
&["clone", &local_url, workspace.to_str().unwrap()],
"clone",
)?;
}
// Checkout target commit — force to discard any local changes
run_git(&workspace, &["checkout", "--force", commit_sha], "checkout")?;
// Checkout target commit — force to discard any local changes. Hard failure:
// this is what decides the tree the job compiles.
run_git_checked(&workspace, &["checkout", "--force", commit_sha], "checkout")?;
// NOTE: no `git clean -fdx` — preserves build caches (deps, _build, node_modules, target/)
@@ -77,7 +389,32 @@
Ok((parts[1].to_string(), parts[0].to_string()))
}
/// Run git, treating a non-zero exit as a warning.
///
/// Only for steps whose failure is recoverable downstream: a failed
/// `remote set-url` or `fetch` is survivable when the target commit is already
/// present locally, and when it isn't, the checkout fails loudly straight after.
/// Anything that decides *which tree the job builds* must use
/// [`run_git_checked`].
fn run_git_lenient(cwd: &Path, args: &[&str], label: &str) {
match Command::new("git").current_dir(cwd).args(args).output() {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("warning: git {label} failed: {stderr}");
}
Err(e) => eprintln!("warning: could not run git {label}: {e}"),
fn run_git(
}
}
/// Run git, propagating a non-zero exit as an error.
///
/// The clone and the checkout decide what source the job compiles, so swallowing
/// their failure lets `prepare` hand back a directory that is empty,
/// half-populated, or still on the previous commit — and the job then reports a
/// pass or a failure for a tree that was never checked out. That is worse than a
/// failed job, because it reads as the developer's commit being broken.
fn run_git_checked(
cwd: &Path,
args: &[&str],
label: &str,
@@ -85,22 +422,295 @@
let output = Command::new("git").current_dir(cwd).args(args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git {label} failed: {}", stderr.trim()).into());
eprintln!("warning: git {label} failed: {stderr}");
}
Ok(())
}
fn run_git_in(
cwd: &Path,
args: &[&str],
label: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
run_git(cwd, args, label)
}
#[cfg(test)]
mod tests {
use super::*;
const WORK: &str = "/work";
const RUNNER_A: &str = "ad2a1b80-5180-43ed-81be-3ae49e6ccdf9";
const RUNNER_B: &str = "ff60fbf0-5c9e-4c01-b853-582a6c3b1ab2";
#[test]
fn two_runners_sharing_a_work_dir_get_separate_workspaces() {
// The bug: instance A slot 1 and instance B slot 1 resolved to the same
// directory, so `git checkout --force` rewrote the other job's tree
// mid-build.
let a = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 1);
let b = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_B, 1);
assert_ne!(a, b);
}
#[test]
fn slots_within_one_runner_stay_separate() {
let s1 = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 1);
let s2 = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 2);
assert_ne!(s1, s2);
}
#[test]
fn workspace_is_stable_across_jobs_for_one_runner_and_slot() {
// Must NOT vary per job/run: the workspace is reused to preserve build
// caches, which is why there is no `git clean`.
assert_eq!(
workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 1),
workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 1),
);
}
#[test]
fn different_repos_stay_separate() {
let a = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 1);
let b = workspace_path(Path::new(WORK), "fangorn", "anvil", RUNNER_A, 1);
assert_ne!(a, b);
}
#[test]
fn instance_key_is_filesystem_safe_and_cannot_escape() {
// Hex output: no separator or traversal can survive, whatever comes in.
for id in ["../../etc/passwd", "", "!!!", RUNNER_A] {
let k = instance_key(id);
assert_eq!(k.len(), 8);
assert!(k.chars().all(|c| c.is_ascii_hexdigit()), "got {k}");
}
}
#[test]
fn instance_key_separates_ids_sharing_a_long_prefix() {
// The reason the key hashes instead of truncating: runner_id is whatever
// the server returns, and prefix-sharing ids must not collapse together
// and restore cross-runner tree-stomping.
assert_ne!(
instance_key("runner-prod-01"),
instance_key("runner-prod-02")
);
assert_ne!(
instance_key("01HXAAAAAAAAAAAAAAAAAAAAA1"),
instance_key("01HXAAAAAAAAAAAAAAAAAAAAA2"),
);
// Ids that sanitize to nothing under the old scheme are still distinct.
assert_ne!(instance_key("!!!"), instance_key("???"));
}
#[test]
fn instance_key_is_deterministic() {
assert_eq!(instance_key(RUNNER_A), instance_key(RUNNER_A));
assert_ne!(instance_key(RUNNER_A), instance_key(RUNNER_B));
}
#[test]
fn workspace_path_layout_is_as_documented() {
let p = workspace_path(Path::new(WORK), "fangorn", "anvil-cli", RUNNER_A, 3);
let expected = format!("/work/fangorn/anvil-cli/{}-3", instance_key(RUNNER_A));
assert_eq!(p, Path::new(&expected));
}
#[test]
fn ephemeral_workspaces_are_disposable_and_others_are_not() {
// Ephemeral runners re-register with a new id per job, so their
// id-keyed workspace can never be reused and must not be left behind.
assert!(is_disposable(true));
assert!(!is_disposable(false));
}
/// A scratch work_dir tree with one workspace, whose mtimes are backdated by
/// `age_days` so the sweep sees it as idle.
fn seeded_work_dir(tag: &str, leaf: &str, age_days: u64) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("anvil-ws-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ws = root.join("fangorn").join("anvil-cli").join(leaf);
std::fs::create_dir_all(ws.join(".git")).unwrap();
std::fs::write(ws.join("marker"), b"x").unwrap();
if age_days > 0 {
backdate_tree(&ws, age_days);
}
(root, ws)
}
/// Age every entry in a workspace, and the workspace itself. Anything
/// written afterwards makes it look fresh again — which is the real
/// behaviour, so tests that seed a lock file must backdate after doing so.
fn backdate_tree(ws: &Path, age_days: u64) {
let when =
std::time::SystemTime::now() - std::time::Duration::from_secs(age_days * 24 * 60 * 60);
// set_modified works on directories too, so the whole workspace can be
// aged without pulling in a crate just for tests.
let mut paths: Vec<PathBuf> = std::fs::read_dir(ws)
.expect("read workspace")
.flatten()
.map(|e| e.path())
.collect();
paths.push(ws.to_path_buf());
for p in paths {
std::fs::File::open(&p)
.and_then(|f| f.set_modified(when))
.unwrap_or_else(|e| panic!("backdate {}: {e}", p.display()));
}
}
#[test]
fn sweep_reclaims_a_long_idle_workspace() {
// Covers both leak classes: a pre-upgrade `<slot>` directory and a
// directory keyed on a runner id that no longer exists.
for leaf in ["1", "deadbeef-1"] {
let (root, ws) = seeded_work_dir("idle", leaf, 30);
assert!(ws.exists());
prune_workspaces(&root, 14);
assert!(!ws.exists(), "{leaf} should have been reclaimed");
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn sweep_keeps_a_recently_used_workspace() {
// A live or recently-used checkout must survive — the sweep judges only
// by age precisely because it cannot tell whose workspace it is.
let (root, ws) = seeded_work_dir("fresh", "deadbeef-1", 0);
prune_workspaces(&root, 14);
assert!(ws.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_notices_activity_in_child_files_not_just_the_directory() {
// An incremental build rewrites files without touching the parent's
// mtime, so an old directory containing a fresh file is still in use.
let (root, ws) = seeded_work_dir("childfresh", "deadbeef-1", 30);
std::fs::write(ws.join("just-written"), b"x").unwrap();
prune_workspaces(&root, 14);
assert!(
ws.exists(),
"recent child activity must protect a workspace"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_leaves_the_owner_repo_levels_alone() {
// Only the leaf is a workspace; the grouping directories must survive
// even when they are themselves old.
let (root, ws) = seeded_work_dir("levels", "1", 30);
prune_workspaces(&root, 14);
assert!(!ws.exists());
assert!(root.join("fangorn").join("anvil-cli").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_tolerates_a_missing_work_dir() {
prune_workspaces(Path::new("/nonexistent/anvil/work"), 14);
}
#[test]
fn guard_applies_each_policy_on_scope_exit() {
// The early-return paths in execute_job rely on Drop, not on reaching
// the end of the function — so every policy must be decided here.
// (policy, disposable, mark_succeeded, expect_kept)
let cases = [
("never", false, false, true),
("never", false, true, true),
("always", false, false, false), // fires even on a failed job
("on-success", false, true, false),
("on-success", false, false, true), // early return ⇒ not a success
("never", true, false, false), // disposable overrides "never"
];
for (i, (policy, disposable, succeeded, keep)) in cases.into_iter().enumerate() {
let (root, ws) = seeded_work_dir(&format!("guard{i}"), "deadbeef-1", 0);
{
let mut g = WorkspaceGuard::new(Some(ws.clone()), policy, disposable);
if succeeded {
g.mark_succeeded();
}
}
assert_eq!(
ws.exists(),
keep,
"policy={policy} disposable={disposable} succeeded={succeeded}"
);
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn sweep_skips_a_workspace_whose_owner_is_alive() {
// Liveness beats age, and needs no clock — so this holds even when the
// host's clock has jumped forward.
let (root, ws) = seeded_work_dir("locked", "deadbeef-1", 0);
write_lock(&ws); // records this (live) process
// Age the tree after locking, so age alone would condemn it and only
// the liveness check can save it.
backdate_tree(&ws, 30);
prune_workspaces(&root, 14);
assert!(ws.exists(), "a live owner must protect its workspace");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_reclaims_a_workspace_whose_owner_is_gone() {
let (root, ws) = seeded_work_dir("deadlock", "deadbeef-1", 0);
// PID 0x7FFFFFFF is never a live process.
std::fs::write(ws.join(LOCK_FILE), "2147483647").unwrap();
// Age the tree *after* seeding the lock, or writing it makes the
// workspace look freshly used.
backdate_tree(&ws, 30);
prune_workspaces(&root, 14);
assert!(!ws.exists(), "a dead owner must not protect a stale tree");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_only_touches_directories_this_module_names() {
// The leaf-name guard: a work_dir pointed at a shared mount must not
// have unrelated data deleted out from under the operator.
for leaf in ["important-data", "notes", "1a2b3c4d-x", "deadbeef", "-1"] {
let (root, dir) = seeded_work_dir("names", leaf, 60);
prune_workspaces(&root, 14);
assert!(
dir.exists(),
"{leaf} is not a workspace name and must survive"
);
let _ = std::fs::remove_dir_all(&root);
}
// ...while the two shapes this module produces are reclaimed.
for leaf in ["deadbeef-1", "1"] {
let (root, dir) = seeded_work_dir("names-ok", leaf, 60);
prune_workspaces(&root, 14);
assert!(
!dir.exists(),
"{leaf} is a workspace name and should be swept"
);
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(unix)]
#[test]
fn sweep_does_not_follow_symlinks_out_of_work_dir() {
// A symlinked owner/repo level must not let the walk escape work_dir.
let outside = std::env::temp_dir().join(format!("anvil-ws-out-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&outside);
let victim = outside.join("anvil-cli").join("deadbeef-1");
std::fs::create_dir_all(&victim).unwrap();
let root = std::env::temp_dir().join(format!("anvil-ws-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&outside, root.join("fangorn")).unwrap();
prune_workspaces(&root, 0);
assert!(
victim.exists(),
"the sweep must not delete through a symlinked level"
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn test_parse_repo_url() {