Skip to main content

fallow_cli/
base_worktree.rs

1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{Duration, SystemTime};
6
7use fallow_engine::changed_files::clear_ambient_git_env;
8pub use fallow_engine::repo_refs::materialize_base_dependency_context;
9use rustc_hash::{FxHashMap, FxHashSet};
10use xxhash_rust::xxh3::xxh3_64;
11
12use crate::report::plural;
13
14pub struct BaseWorktree {
15    path: PathBuf,
16    persistent: bool,
17    _reusable_lock: Option<ReusableWorktreeLock>,
18}
19
20impl BaseWorktree {
21    /// `workspace_patterns` are the `workspaces.patterns` of the resolved
22    /// config, so the base view links the context of those packages too.
23    pub fn create(
24        repo_root: &Path,
25        base_ref: &str,
26        base_sha: Option<&str>,
27        workspace_patterns: &[String],
28    ) -> Option<Self> {
29        sweep_orphan_audit_worktrees(repo_root);
30        if let Some(base_sha) = base_sha
31            && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha, workspace_patterns)
32        {
33            return Some(worktree);
34        }
35        let path = non_reusable_worktree_path()?;
36        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
37        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
38            repo_root,
39            guard.path(),
40            base_ref,
41        ) {
42            tracing::debug!(
43                base_ref,
44                error = %error,
45                "could not materialize non-reusable audit base worktree",
46            );
47            return None;
48        }
49        // Deregister immediately so a crash before Drop never leaves a git
50        // worktree admin entry behind (issue #1815). No early return runs
51        // between here and the struct binding, so defusing the guard next is
52        // safe: the entry is already unregistered.
53        if let Err(error) = unregister_worktree(repo_root, guard.path()) {
54            tracing::debug!(
55                path = %guard.path().display(),
56                error = %error,
57                "could not deregister non-reusable audit base worktree",
58            );
59            return None;
60        }
61        guard.defuse();
62        drop(guard);
63        let worktree = Self {
64            path,
65            persistent: false,
66            _reusable_lock: None,
67        };
68        materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
69        Some(worktree)
70    }
71
72    pub fn reuse_or_create(
73        repo_root: &Path,
74        base_sha: &str,
75        workspace_patterns: &[String],
76    ) -> Option<Self> {
77        let path = reusable_audit_worktree_path(repo_root);
78        let reusable_lock =
79            ReusableWorktreeLock::try_acquire(&path, "falling back to non-reusable worktree")?;
80
81        if reusable_audit_worktree_is_ready(&path, base_sha)
82            || try_migrate_registered_current_cache(repo_root, &path, base_sha)
83        {
84            let worktree = Self {
85                path,
86                persistent: true,
87                _reusable_lock: Some(reusable_lock),
88            };
89            materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
90            record_last_used(worktree.path(), repo_root);
91            return Some(worktree);
92        }
93
94        if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
95            tracing::debug!(
96                path = %path.display(),
97                error = %error,
98                "could not clear reusable audit worktree readiness before rebuild",
99            );
100            return None;
101        }
102        if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
103            tracing::debug!(
104                path = %path.display(),
105                error = %error,
106                "could not remove stale reusable audit worktree before rebuild",
107            );
108            return None;
109        }
110        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
111        if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
112            repo_root,
113            guard.path(),
114            base_sha,
115        ) {
116            tracing::debug!(
117                base_sha,
118                error = %error,
119                "could not materialize reusable audit base worktree",
120            );
121            return None;
122        }
123        // Deregister while keeping the directory, then atomically publish the
124        // full base SHA through the `.sha` sidecar. Publication happens only
125        // after successful materialization and deregistration under the lock,
126        // so a torn snapshot is never advertised as ready to the next run.
127        if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
128            tracing::debug!(
129                path = %guard.path().display(),
130                error = %error,
131                "could not deregister reusable audit base worktree",
132            );
133            return None;
134        }
135        guard.defuse();
136        drop(guard);
137        let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
138
139        let worktree = Self {
140            path,
141            persistent: true,
142            _reusable_lock: Some(reusable_lock),
143        };
144        materialize_base_dependency_context(repo_root, worktree.path(), workspace_patterns);
145        if readiness_published {
146            record_last_used(worktree.path(), repo_root);
147        }
148        Some(worktree)
149    }
150
151    pub fn path(&self) -> &Path {
152        &self.path
153    }
154}
155
156/// Build a unique temp path for a non-reusable base worktree.
157///
158/// The pid stays the FIRST `-`-separated segment so [`audit_worktree_pid`] and
159/// the orphan sweep keep working. A process-global monotonic counter is the
160/// final segment: the wall-clock nanos read is NOT monotonic and repeats across
161/// threads, so two `audit` runs in one process (e.g. parallel unit tests, or a
162/// future in-process batch) could otherwise mint the same path and race on
163/// `git worktree add`, where the loser fails and the audit aborts with a generic
164/// error. The counter makes every path distinct regardless of clock resolution.
165fn non_reusable_worktree_path() -> Option<PathBuf> {
166    static SEQ: AtomicU64 = AtomicU64::new(0);
167    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
168    let nanos = SystemTime::now()
169        .duration_since(SystemTime::UNIX_EPOCH)
170        .ok()?
171        .as_nanos();
172    Some(std::env::temp_dir().join(format!(
173        "fallow-audit-base-{}-{nanos}-{seq}",
174        std::process::id()
175    )))
176}
177
178/// RAII cleanup guard for a freshly-created git worktree directory.
179///
180/// Armed before the `git worktree add` subprocess runs. If the holder returns
181/// early (`?`) between subprocess success and the `BaseWorktree` struct binding,
182/// `Drop` rolls back BOTH git's `.git/worktrees/<name>` registration AND the
183/// on-disk directory. The owner calls `defuse()` once `BaseWorktree` is bound
184/// and takes over cleanup via its own `Drop`.
185///
186/// With `panic = "abort"` on the release profile, this does not provide
187/// panic-recovery cleanup (no unwind runs), but it is still load-bearing for
188/// every early-return path between subprocess success and struct construction.
189pub struct WorktreeCleanupGuard<'a> {
190    repo_root: PathBuf,
191    path: &'a Path,
192    armed: bool,
193}
194
195impl<'a> WorktreeCleanupGuard<'a> {
196    pub fn new(repo_root: &Path, path: &'a Path) -> Self {
197        Self {
198            repo_root: repo_root.to_path_buf(),
199            path,
200            armed: true,
201        }
202    }
203
204    pub fn path(&self) -> &Path {
205        self.path
206    }
207
208    /// Disarm in place. Idempotent; calling twice is harmless. Drop becomes a
209    /// no-op after this returns.
210    pub fn defuse(&mut self) {
211        self.armed = false;
212    }
213}
214
215impl Drop for WorktreeCleanupGuard<'_> {
216    fn drop(&mut self) {
217        if self.armed {
218            remove_audit_worktree(&self.repo_root, self.path);
219            let _ = std::fs::remove_dir_all(self.path);
220        }
221    }
222}
223
224/// Kernel-level advisory lock around the reusable-cache `reuse_or_create`
225/// critical section, backed by `std::fs::File::try_lock` (stable since Rust
226/// 1.89), which wraps `flock(2)` on Unix and `LockFileEx` on Windows.
227/// Concurrent acquirers either fall through (`None`) or observe a
228/// freshly-prepared cache after the holder releases.
229pub struct ReusableWorktreeLock {
230    file: std::fs::File,
231}
232
233impl ReusableWorktreeLock {
234    /// `context` names the caller's contention fallback in the debug
235    /// diagnostic ("falls back to a non-reusable worktree" is only true for
236    /// `reuse_or_create`; a GC sweep or prune skips the entry instead).
237    pub fn try_acquire(reusable_path: &Path, context: &'static str) -> Option<Self> {
238        let lock_path = reusable_worktree_lock_path(reusable_path);
239        let file = open_or_create_owned_sidecar(&lock_path).ok()?;
240        match file.try_lock() {
241            Ok(()) => Some(Self { file }),
242            Err(std::fs::TryLockError::WouldBlock) => {
243                tracing::debug!(
244                    path = %lock_path.display(),
245                    context,
246                    "reusable audit worktree lock contended",
247                );
248                None
249            }
250            Err(std::fs::TryLockError::Error(err)) => {
251                tracing::debug!(
252                    path = %lock_path.display(),
253                    error = %err,
254                    context,
255                    "could not acquire reusable audit worktree lock",
256                );
257                None
258            }
259        }
260    }
261}
262
263impl Drop for ReusableWorktreeLock {
264    fn drop(&mut self) {
265        let _ = self.file.unlock();
266    }
267}
268
269pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
270    sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
271}
272
273/// Build a sidecar path `<cache dir name><suffix>` next to (NOT inside) the
274/// reusable cache directory, so the sidecar survives `git worktree`
275/// operations and directory materialization on the cache dir itself.
276fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
277    let mut name = reusable_path
278        .file_name()
279        .map(std::ffi::OsString::from)
280        .unwrap_or_default();
281    name.push(suffix);
282    reusable_path
283        .parent()
284        .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
285}
286
287/// Sidecar path recording the base SHA a reusable cache entry was
288/// materialized at. Lives next to the cache directory like the `.last-used` /
289/// `.lock` sidecars, so readiness can be verified without a `git` subprocess.
290pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
291    sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
292}
293
294/// Record the base SHA a reusable cache holds. Failure is non-fatal: this run
295/// proceeds and the next run rebuilds (a missing `.sha` reads as not-ready).
296fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
297    static SEQ: AtomicU64 = AtomicU64::new(0);
298
299    let sha_path = reusable_worktree_sha_path(reusable_path);
300    let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
301    let temp_path = sidecar_path(
302        reusable_path,
303        &format!(
304            "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
305            std::process::id()
306        ),
307    );
308    let result = (|| {
309        let mut options = std::fs::OpenOptions::new();
310        options.create_new(true).write(true);
311        #[cfg(unix)]
312        {
313            use std::os::unix::fs::OpenOptionsExt as _;
314            options.mode(0o600);
315        }
316        let mut file = options.open(&temp_path)?;
317        file.write_all(format!("{base_sha}\n").as_bytes())?;
318        file.sync_all()?;
319        std::fs::rename(&temp_path, &sha_path)
320    })();
321    if let Err(err) = &result {
322        let _ = std::fs::remove_file(&temp_path);
323        tracing::debug!(
324            path = %sha_path.display(),
325            error = %err,
326            "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
327        );
328    }
329    result
330}
331
332/// Default GC threshold for persistent reusable base-snapshot caches.
333const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
334
335const SECONDS_PER_DAY: u64 = 86_400;
336
337/// Env var that overrides `audit.cacheMaxAgeDays` from the config.
338const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
339
340/// Sidecar filename suffix used to track last-use of a reusable worktree.
341const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
342
343/// Sidecar filename suffix recording the base SHA a reusable cache holds.
344const REUSABLE_SHA_SUFFIX: &str = ".sha";
345
346/// Sidecar filename suffix of the reuse-lock file.
347const REUSABLE_LOCK_SUFFIX: &str = ".lock";
348
349/// Invalid gitdir pointer written into `<cache>/.git` after deregistering a
350/// transient audit worktree (issue #1815).
351///
352/// The `.git` file is REPLACED, never deleted: both discovery walkers use the
353/// `ignore` crate with `require_git` on, whose gitignore handling is gated on
354/// `<root>/.git` existing. Deleting the gitfile would silently stop the base
355/// pass from honoring `.gitignore`, inflating base findings and skewing
356/// audit's introduced-vs-inherited split. The stub keeps gitignore parity
357/// while pointing at a nonexistent gitdir, so any stray `git` command inside
358/// the snapshot fails loudly instead of operating on the host repo.
359const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
360
361/// Sidecar path for the "last used" timestamp of a reusable cache entry.
362///
363/// Lives next to the cache directory (NOT inside it) so the sidecar is
364/// untouched by `git worktree add/remove` on the cache directory itself.
365pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
366    sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
367}
368
369/// Stamp the sidecar `.last-used` file's mtime to now.
370///
371/// Called on every cache-hit reuse (and from the pre-upgrade-grace branch
372/// of the GC sweep) so the staleness signal stays current even when the
373/// cache directory itself is not mutated. Failures are surfaced at
374/// `warn!` so a persistent ENOSPC / read-only-tmp condition is visible at
375/// default `RUST_LOG=warn`; the caller does not abort the audit.
376pub fn touch_last_used(reusable_path: &Path) {
377    stamp_last_used(reusable_path, None);
378}
379
380/// Stamp `.last-used` like [`touch_last_used`] and additionally record
381/// `owner_root` (the requested analysis root that owns this cache) as the
382/// sidecar's content. The cross-repo GC pass reads it back to decide whether
383/// an entry outside the current repo's scope is still owned by a live project
384/// (issue #2169): caches whose recorded owner no longer exists on disk are
385/// abandoned and become eligible for age-based reclaim from any repo's sweep.
386pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
387    stamp_last_used(reusable_path, Some(owner_root));
388}
389
390fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
391    let last_used = reusable_worktree_last_used_path(reusable_path);
392    let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
393        if let Some(owner_root) = owner_root {
394            file.set_len(0)?;
395            file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
396        }
397        file.set_modified(SystemTime::now())
398    });
399    if let Err(err) = result {
400        tracing::warn!(
401            path = %last_used.display(),
402            error = %err,
403            "failed to touch reusable audit worktree sidecar; staleness signal may not update",
404        );
405    }
406}
407
408/// Read the owner root recorded in a cache entry's `.last-used` sidecar.
409/// Returns `None` for missing, empty (pre-#2169), oversized, or non-file
410/// sidecars. The path is only ever used for an existence probe, never as a
411/// removal target, so untrusted content cannot redirect the sweep.
412fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
413    const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
414
415    let sidecar = reusable_worktree_last_used_path(reusable_path);
416    let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
417    if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
418        return None;
419    }
420    let mut contents = String::new();
421    std::fs::File::open(sidecar)
422        .ok()?
423        .take(MAX_OWNER_SIDECAR_BYTES)
424        .read_to_string(&mut contents)
425        .ok()?;
426    let owner = contents.trim();
427    if owner.is_empty() {
428        return None;
429    }
430    Some(PathBuf::from(owner))
431}
432
433fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
434    match std::fs::symlink_metadata(path) {
435        Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
436            std::fs::OpenOptions::new().write(true).open(path)
437        }
438        Ok(_) => Err(std::io::Error::new(
439            std::io::ErrorKind::PermissionDenied,
440            "refusing to open an untrusted audit cache sidecar",
441        )),
442        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
443            let mut options = std::fs::OpenOptions::new();
444            options.create_new(true).write(true);
445            #[cfg(unix)]
446            {
447                use std::os::unix::fs::OpenOptionsExt as _;
448                options.mode(0o600);
449            }
450            options.open(path)
451        }
452        Err(error) => Err(error),
453    }
454}
455
456#[cfg(unix)]
457fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
458    use std::os::unix::fs::MetadataExt as _;
459
460    metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
461}
462
463#[cfg(not(unix))]
464fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
465    metadata_is_regular_file(metadata)
466}
467
468#[expect(
469    clippy::filetype_is_file,
470    reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
471)]
472fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
473    metadata.file_type().is_file()
474}
475
476/// Which precedence level supplied the effective cache GC threshold.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum CacheMaxAgeSource {
479    Flag,
480    Env,
481    Config,
482    Default,
483}
484
485impl CacheMaxAgeSource {
486    #[must_use]
487    pub const fn as_str(self) -> &'static str {
488        match self {
489            Self::Flag => "flag",
490            Self::Env => "env",
491            Self::Config => "config",
492            Self::Default => "default",
493        }
494    }
495}
496
497/// Effective GC threshold plus the precedence level that supplied it.
498/// `max_age` is `None` when the winning value is `0` (age-based GC disabled).
499#[derive(Debug, Clone, Copy)]
500pub struct ResolvedCacheMaxAge {
501    pub max_age: Option<Duration>,
502    pub days: u32,
503    pub source: CacheMaxAgeSource,
504}
505
506/// Resolve the GC threshold for persistent reusable caches.
507///
508/// Precedence: `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` env var > `audit.cacheMaxAgeDays`
509/// config field > 30-day default. `0` from either source disables the sweep
510/// entirely (returns `None`). Invalid env values (non-integer) warn and fall
511/// back to config / default; audits do not fail on a typo in a runner env var.
512pub fn resolve_cache_max_age_with_options(
513    root: &Path,
514    config_path: Option<&PathBuf>,
515    allow_remote_extends: bool,
516) -> Option<Duration> {
517    resolve_cache_max_age_with_source(root, config_path, allow_remote_extends, None).max_age
518}
519
520/// [`resolve_cache_max_age_with_options`] with an optional per-invocation
521/// override (`fallow audit-cache prune --max-age-days`) that wins over every
522/// other level, and the winning source reported for display.
523pub fn resolve_cache_max_age_with_source(
524    root: &Path,
525    config_path: Option<&PathBuf>,
526    allow_remote_extends: bool,
527    flag_days: Option<u32>,
528) -> ResolvedCacheMaxAge {
529    if let Some(days) = flag_days {
530        return ResolvedCacheMaxAge {
531            max_age: days_to_duration(days),
532            days,
533            source: CacheMaxAgeSource::Flag,
534        };
535    }
536    if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
537        if let Ok(days) = raw.trim().parse::<u32>() {
538            return ResolvedCacheMaxAge {
539                max_age: days_to_duration(days),
540                days,
541                source: CacheMaxAgeSource::Env,
542            };
543        }
544        tracing::warn!(
545            value = %raw,
546            "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
547        );
548    }
549    if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
550        .and_then(|c| c.cache_max_age_days)
551    {
552        return ResolvedCacheMaxAge {
553            max_age: days_to_duration(days),
554            days,
555            source: CacheMaxAgeSource::Config,
556        };
557    }
558    ResolvedCacheMaxAge {
559        max_age: days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS),
560        days: DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS,
561        source: CacheMaxAgeSource::Default,
562    }
563}
564
565pub fn days_to_duration(days: u32) -> Option<Duration> {
566    if days == 0 {
567        return None;
568    }
569    Some(Duration::from_secs(u64::from(days) * SECONDS_PER_DAY))
570}
571
572/// Load `AuditConfig` from `opts.config_path` (or auto-discover from
573/// `opts.root`) for GC-threshold resolution only. Errors silently fall
574/// back to `None`; the caller defaults to a 30-day window.
575fn load_audit_config(
576    root: &Path,
577    config_path: Option<&PathBuf>,
578    allow_remote_extends: bool,
579) -> Option<fallow_config::AuditConfig> {
580    let options = fallow_config::ConfigLoadOptions {
581        allow_remote_extends,
582    };
583    if let Some(path) = config_path {
584        return fallow_config::FallowConfig::load_with_options(path, options)
585            .ok()
586            .map(|config| config.audit);
587    }
588    fallow_config::FallowConfig::find_and_load_with_options(root, options)
589        .ok()
590        .flatten()
591        .map(|(config, _path)| config.audit)
592}
593
594/// Reclaim persistent reusable base-snapshot worktree caches.
595///
596/// Two reclaim conditions, checked per entry:
597/// - Prunable orphan: the cache directory no longer exists (an external
598///   `$TMPDIR` reaper, a container restart, or a CI cache eviction deleted it
599///   but left git's admin entry behind). Reclaimed eagerly, independent of
600///   `max_age`, because the `.last-used` sidecar lives next to the deleted
601///   directory and survives the reaper, so the age branch would re-touch a
602///   fresh sidecar and never reclaim the dead entry. Passing `max_age = None`
603///   (age-based GC disabled) still runs this reclaim.
604/// - Aged-out: the sidecar `.last-used` file is older than `max_age` (only
605///   when `max_age` is `Some`).
606///
607/// Entries under other repo hashes (other linked worktrees, deleted or moved
608/// repos) are visited by a cross-repo pass; see
609/// [`reclaim_foreign_cache_entry`] for its owner-liveness gate (issue #2169).
610///
611/// Concurrency: each candidate is gated by [`ReusableWorktreeLock`] before
612/// removal, so an in-flight `fallow audit` mid-rebuild against the same
613/// cache entry will not be disturbed (the sweep skips on contention). The
614/// orphan branch re-checks existence under the lock so a rebuild that
615/// recreated the directory between the check and the lock is preserved.
616///
617/// Pre-upgrade caches lacking a sidecar are NOT removed: instead the sweep
618/// seeds a fresh sidecar so the next invocation can age them from real
619/// last-use. Without this grace, the dir's own mtime (= creation date on
620/// POSIX) would wipe every legitimately-warm pre-upgrade cache on the
621/// first run after upgrade.
622///
623/// The `.lock` sidecar file is intentionally NOT deleted on removal: a
624/// racing acquirer of an unlinked-but-still-flocked inode plus a sibling
625/// `open(O_CREAT)` at the same path would produce two processes each
626/// holding a kernel flock on different inodes. Lock files are tens of
627/// bytes; leaking them is harmless.
628pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
629    sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
630}
631
632/// Scan-root-injectable body of [`sweep_old_reusable_caches`]. Production
633/// always passes `std::env::temp_dir()`; tests pass a private per-test
634/// directory so one test's cross-repo pass never scans (and reclaims)
635/// another test's fixtures or a developer's real ownerless caches in the
636/// shared temp dir.
637pub fn sweep_old_reusable_caches_in(
638    repo_root: &Path,
639    max_age: Option<Duration>,
640    quiet: bool,
641    scan_root: &Path,
642) {
643    let report = sweep_reusable_caches_with_report(
644        repo_root,
645        max_age,
646        scan_root,
647        SweepMode::Apply,
648        SweepSizes::Skip,
649    );
650    log_sweep_entries(&report, SweepMode::Apply, max_age);
651    let removed = report.removed;
652    if removed == 0 {
653        return;
654    }
655    tracing::info!(
656        count = removed,
657        "reclaimed stale audit base-snapshot caches",
658    );
659    if !quiet {
660        let s = plural(removed as usize);
661        let _ = writeln!(
662            std::io::stderr(),
663            "fallow: reclaimed {removed} stale base-snapshot cache{s}",
664        );
665    }
666}
667
668/// How a cache sweep executes.
669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum SweepMode {
671    /// Normal GC: acquire locks, seed grace sidecars, remove entries.
672    Apply,
673    /// Preview: dispositions are computed from read-only probes only. No lock
674    /// acquisition (`try_acquire` would create `.lock` sidecars), no grace
675    /// seeding, no removal, no `git` worktree deregistration or prune.
676    DryRun,
677}
678
679impl SweepMode {
680    #[must_use]
681    pub const fn as_str(self) -> &'static str {
682        match self {
683            Self::Apply => "apply",
684            Self::DryRun => "dry-run",
685        }
686    }
687}
688
689/// Whether the sweep measures per-entry directory sizes. Measuring walks the
690/// full cache tree, so only the manual prune command asks for it; the
691/// per-audit sweep stays IO-light.
692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693pub enum SweepSizes {
694    Skip,
695    Measure,
696}
697
698/// Which enumeration pass produced a sweep candidate.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum SweepPass {
701    /// The requested root's own cache entry.
702    Owned,
703    /// Old SHA-suffixed entries under this repo's hash.
704    Legacy,
705    /// Entries under other repo hashes (cross-repo pass, issue #2169).
706    Foreign,
707}
708
709impl SweepPass {
710    #[must_use]
711    pub const fn as_str(self) -> &'static str {
712        match self {
713            Self::Owned => "owned",
714            Self::Legacy => "legacy",
715            Self::Foreign => "foreign",
716        }
717    }
718}
719
720/// Decision bucket of a [`SweepDisposition`], the closed `disposition` set of
721/// the prune JSON envelope.
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
723pub enum SweepDecision {
724    Removed,
725    Kept,
726    Skipped,
727    Failed,
728}
729
730impl SweepDecision {
731    #[must_use]
732    pub const fn as_str(self) -> &'static str {
733        match self {
734            Self::Removed => "removed",
735            Self::Kept => "kept",
736            Self::Skipped => "skipped",
737            Self::Failed => "failed",
738        }
739    }
740}
741
742/// Per-entry outcome of one sweep candidate.
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744pub enum SweepDisposition {
745    /// Sidecars of a cache directory an external reaper already deleted.
746    ReclaimedOrphan,
747    /// Entry aged past the effective threshold.
748    ReclaimedAged,
749    /// Foreign entry whose recorded owner root is gone, reclaimed while
750    /// age-based GC is disabled (`cacheMaxAgeDays = 0`). Nothing will ever
751    /// sweep it under its own hash again, so the age gate that `0` switches
752    /// off would otherwise strand it on disk forever.
753    ReclaimedOwnerMissing,
754    /// A released SHA-keyed directory still REGISTERED as a git worktree by
755    /// pre-#1815 fallow; the root-owned cache cannot reuse it, so it is
756    /// deregistered AND removed.
757    ReclaimedLegacyRegistered,
758    /// The current cache path still REGISTERED as a git worktree by pre-#1815
759    /// fallow (a mixed-version registration); only the git registration is
760    /// reclaimed and the directory stays warm on disk, so its measured size
761    /// never counts as reclaimed bytes.
762    KeptLegacyDeregistered,
763    /// Younger than the threshold.
764    KeptFresh,
765    /// Foreign entry whose recorded owner root still exists; that repo's own
766    /// sweep governs it.
767    KeptOwnerLive,
768    /// Foreign entry whose owner probe failed with a non-NotFound error; kept
769    /// so a transient probe failure can never reclaim a live repo's cache.
770    KeptOwnerUnverifiable(std::io::ErrorKind),
771    /// Age-based GC disabled (`cacheMaxAgeDays = 0`) and the entry is not an
772    /// orphan.
773    KeptAgeGcDisabled,
774    /// First encounter of a pre-upgrade entry without a `.last-used` sidecar;
775    /// a sidecar is seeded so it ages from real last-use.
776    KeptGraceSeeded,
777    /// Only a `.lock` sidecar survives (never deleted by design); there is no
778    /// directory, `.sha`, or `.last-used` left to reclaim.
779    KeptLockOnly,
780    /// The orphan re-check under the lock found the directory rebuilt by a
781    /// concurrent run.
782    KeptRecreated,
783    /// The Unix uid ownership check rejects removal (another user's entry on
784    /// a shared runner).
785    KeptNotOwned,
786    /// Lock contention; a concurrent fallow holds this entry.
787    SkippedLocked,
788    /// A genuine IO error after the ownership check passed.
789    RemoveFailed,
790}
791
792impl SweepDisposition {
793    #[must_use]
794    pub const fn decision(self) -> SweepDecision {
795        match self {
796            Self::ReclaimedOrphan
797            | Self::ReclaimedAged
798            | Self::ReclaimedOwnerMissing
799            | Self::ReclaimedLegacyRegistered => SweepDecision::Removed,
800            Self::KeptLegacyDeregistered
801            | Self::KeptFresh
802            | Self::KeptOwnerLive
803            | Self::KeptOwnerUnverifiable(_)
804            | Self::KeptAgeGcDisabled
805            | Self::KeptGraceSeeded
806            | Self::KeptLockOnly
807            | Self::KeptRecreated
808            | Self::KeptNotOwned => SweepDecision::Kept,
809            Self::SkippedLocked => SweepDecision::Skipped,
810            Self::RemoveFailed => SweepDecision::Failed,
811        }
812    }
813
814    /// Closed `reason` vocabulary of the prune JSON envelope and the debug
815    /// diagnostics.
816    #[must_use]
817    pub const fn reason(self) -> &'static str {
818        match self {
819            Self::ReclaimedOrphan => "orphaned-sidecars",
820            Self::ReclaimedAged => "aged-out",
821            Self::ReclaimedOwnerMissing => "owner-missing",
822            Self::ReclaimedLegacyRegistered => "legacy-registered",
823            Self::KeptLegacyDeregistered => "legacy-deregistered",
824            Self::KeptFresh => "fresh",
825            Self::KeptOwnerLive => "owner-live",
826            Self::KeptOwnerUnverifiable(_) => "owner-unverifiable",
827            Self::KeptAgeGcDisabled => "age-gc-disabled",
828            Self::KeptGraceSeeded => "grace-seeded",
829            Self::KeptLockOnly => "lock-only",
830            Self::KeptRecreated => "recreated",
831            Self::KeptNotOwned => "not-owned",
832            Self::SkippedLocked => "lock-contention",
833            Self::RemoveFailed => "remove-failed",
834        }
835    }
836
837    /// Whether this disposition increments the legacy `removed` counter used
838    /// by the per-audit stderr summary. Legacy-registered reclaims never did.
839    const fn counts_toward_summary(self) -> bool {
840        matches!(
841            self,
842            Self::ReclaimedOrphan | Self::ReclaimedAged | Self::ReclaimedOwnerMissing
843        )
844    }
845}
846
847/// One considered candidate of a cache sweep.
848#[derive(Debug, Clone)]
849pub struct SweepEntry {
850    pub path: PathBuf,
851    pub pass: SweepPass,
852    pub disposition: SweepDisposition,
853    /// Whole days since the `.last-used` sidecar mtime, when readable.
854    pub age_days: Option<u64>,
855    /// Owner root recorded in the `.last-used` sidecar, when present.
856    pub owner_root: Option<PathBuf>,
857    /// Recursive directory size, measured only under [`SweepSizes::Measure`]
858    /// and `None` when the directory is absent or unreadable.
859    pub size_bytes: Option<u64>,
860}
861
862/// Full result of one sweep. `removed` keeps the pre-#2221 counter semantics
863/// (orphan and aged reclaims from the owned/legacy and cross-repo passes) so
864/// the audit stderr summary is unchanged.
865#[derive(Debug, Default)]
866pub struct SweepReport {
867    pub entries: Vec<SweepEntry>,
868    pub removed: u32,
869}
870
871impl SweepReport {
872    fn push(&mut self, entry: SweepEntry) {
873        if entry.disposition.counts_toward_summary() {
874            self.removed += 1;
875        }
876        self.entries.push(entry);
877    }
878}
879
880const GC_LOCK_CONTEXT: &str = "gc sweep skips the entry";
881
882/// Emit one `tracing::debug!` line per considered entry. Shared by the
883/// implicit per-audit sweep and `fallow audit-cache prune`, so both surfaces
884/// produce identical diagnostics under an explicit `RUST_LOG` directive.
885pub fn log_sweep_entries(report: &SweepReport, mode: SweepMode, max_age: Option<Duration>) {
886    let threshold_days = max_age.map(|age| age.as_secs() / SECONDS_PER_DAY);
887    for entry in &report.entries {
888        let owner_probe_error = match entry.disposition {
889            SweepDisposition::KeptOwnerUnverifiable(kind) => Some(kind),
890            _ => None,
891        };
892        tracing::debug!(
893            path = %entry.path.display(),
894            pass = entry.pass.as_str(),
895            mode = mode.as_str(),
896            decision = entry.disposition.decision().as_str(),
897            reason = entry.disposition.reason(),
898            age_days = entry.age_days,
899            threshold_days,
900            owner_root = entry
901                .owner_root
902                .as_ref()
903                .map(|owner| tracing::field::display(owner.display())),
904            owner_probe_error = owner_probe_error.map(tracing::field::debug),
905            "audit cache sweep considered entry",
906        );
907    }
908}
909
910/// Report-producing body shared by the per-audit GC sweep
911/// ([`sweep_old_reusable_caches_in`], [`SweepMode::Apply`], sizes skipped)
912/// and `fallow audit-cache prune` (both modes, sizes measured). Running the
913/// same code path is what guarantees prune "runs the same reclaim logic"
914/// (issue #2221) by construction.
915pub fn sweep_reusable_caches_with_report(
916    repo_root: &Path,
917    max_age: Option<Duration>,
918    scan_root: &Path,
919    mode: SweepMode,
920    sizes: SweepSizes,
921) -> SweepReport {
922    let now = SystemTime::now();
923    let mut report = SweepReport::default();
924
925    // Legacy pass: deregister reusable caches left REGISTERED by pre-#1815
926    // fallow (the reporter's `git worktree list` backlog). This is what makes
927    // those entries vanish on the first post-upgrade audit; the `--expire=now`
928    // prune is retained ONLY here to also sweep any admin entry orphaned by a
929    // crash in the transient registration window.
930    legacy_registered_pass(repo_root, mode, sizes, now, &mut report);
931
932    // Primary pass: visit this requested root's cache plus legacy SHA-suffixed
933    // entries from the old git-top-level identity. `git worktree list` no
934    // longer sees the deregistered caches.
935    let owned_path = reusable_audit_worktree_path(repo_root);
936    let mut paths = vec![owned_path.clone()];
937    paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
938    paths.sort();
939    paths.dedup();
940
941    // Cross-repo pass (issue #2169): every other repo hash accumulates caches
942    // the repo-scoped pass can never see (one per linked git worktree, plus
943    // entries from deleted or moved repos that no surviving sweep covers).
944    // Entries whose recorded owner root still exists are left to that repo's
945    // own sweep, so its `cacheMaxAgeDays` setting, including `0`, governs;
946    // the rest are abandoned and age out under this run's threshold.
947    let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
948    let foreign: Vec<PathBuf> = scan_all_reusable_cache_paths(scan_root)
949        .into_iter()
950        .filter(|path| !scoped.contains(path))
951        .collect();
952    drop(scoped);
953
954    // Sizes are measured BEFORE the decision pass so removed entries still
955    // report what they occupied.
956    let mut size_map = measure_entry_sizes(sizes, paths.iter().chain(foreign.iter()));
957
958    for path in &paths {
959        // The requested root's path is a synthetic candidate (always added);
960        // when nothing exists for it on disk (no directory, sidecars, or
961        // `.lock`) there is nothing to consider or report, and probing it
962        // under `Apply` would mint a `.lock` sidecar for a cache that was
963        // never built. Scan-derived candidates always have some presence.
964        if !cache_entry_has_presence(path) {
965            continue;
966        }
967        let pass = if *path == owned_path {
968            SweepPass::Owned
969        } else {
970            SweepPass::Legacy
971        };
972        let (age_days, owner_root) = entry_probe_metadata(path, now);
973        let disposition = match mode {
974            SweepMode::Apply => reclaim_reusable_cache_entry(repo_root, path, max_age, now),
975            SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::Off),
976        };
977        report.push(SweepEntry {
978            path: path.clone(),
979            pass,
980            disposition,
981            age_days,
982            owner_root,
983            size_bytes: size_map.remove(path).flatten(),
984        });
985    }
986    for path in &foreign {
987        let (age_days, owner_root) = entry_probe_metadata(path, now);
988        let disposition = match mode {
989            SweepMode::Apply => reclaim_foreign_cache_entry(repo_root, path, max_age, now),
990            SweepMode::DryRun => classify_entry_dry_run(path, max_age, now, OwnerGate::On),
991        };
992        report.push(SweepEntry {
993            path: path.clone(),
994            pass: SweepPass::Foreign,
995            disposition,
996            age_days,
997            owner_root,
998            size_bytes: size_map.remove(path).flatten(),
999        });
1000    }
1001    report
1002}
1003
1004/// Read-only per-entry metadata for reporting, gathered BEFORE the decision
1005/// pass may remove the sidecars it comes from.
1006fn entry_probe_metadata(path: &Path, now: SystemTime) -> (Option<u64>, Option<PathBuf>) {
1007    let age_days = last_used_mtime(path)
1008        .and_then(|mtime| now.duration_since(mtime).ok())
1009        .map(|age| age.as_secs() / SECONDS_PER_DAY);
1010    (age_days, read_last_used_owner(path))
1011}
1012
1013/// Deregister reusable base-snapshot caches left REGISTERED by fallow versions
1014/// before #1815. A mixed-version registration at the current path stays warm
1015/// and reports [`SweepDisposition::KeptLegacyDeregistered`] (only the git
1016/// registration goes away, so nothing is reclaimed); released SHA-keyed paths
1017/// are removed because the root-owned cache cannot reuse them. Under
1018/// [`SweepMode::DryRun`] only the read-only halves run
1019/// (`list_audit_worktrees`, `audit_worktree_is_registered`): registered
1020/// entries are reported without locks, deregistration, removal, or the
1021/// trailing `git worktree prune`.
1022fn legacy_registered_pass(
1023    repo_root: &Path,
1024    mode: SweepMode,
1025    sizes: SweepSizes,
1026    now: SystemTime,
1027    report: &mut SweepReport,
1028) {
1029    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1030        return;
1031    };
1032    let candidates: Vec<PathBuf> = worktrees
1033        .into_iter()
1034        .filter(|path| is_reusable_audit_worktree_path(path))
1035        .collect();
1036    let mut size_map = measure_entry_sizes(sizes, candidates.iter());
1037    let owned_path = reusable_audit_worktree_path(repo_root);
1038    let mut deregistered = false;
1039    for path in candidates {
1040        let pass = if paths_equal(&path, &owned_path) {
1041            SweepPass::Owned
1042        } else {
1043            SweepPass::Legacy
1044        };
1045        let (age_days, owner_root) = entry_probe_metadata(&path, now);
1046        let size_bytes = size_map.remove(&path).flatten();
1047        let is_current_path = pass == SweepPass::Owned;
1048        let disposition = match mode {
1049            SweepMode::DryRun => {
1050                if !audit_worktree_is_registered(repo_root, &path) {
1051                    continue;
1052                }
1053                if is_current_path {
1054                    SweepDisposition::KeptLegacyDeregistered
1055                } else {
1056                    SweepDisposition::ReclaimedLegacyRegistered
1057                }
1058            }
1059            SweepMode::Apply => {
1060                let Some(_lock) = ReusableWorktreeLock::try_acquire(
1061                    &path,
1062                    "legacy deregistration skips the entry",
1063                ) else {
1064                    report.push(SweepEntry {
1065                        path,
1066                        pass,
1067                        disposition: SweepDisposition::SkippedLocked,
1068                        age_days,
1069                        owner_root,
1070                        size_bytes,
1071                    });
1072                    continue;
1073                };
1074                if !audit_worktree_is_registered(repo_root, &path) {
1075                    continue;
1076                }
1077                let head = is_current_path
1078                    .then(|| legacy_reusable_sha(&path))
1079                    .flatten();
1080                if unregister_worktree_checked(repo_root, &path).is_err() {
1081                    report.push(SweepEntry {
1082                        path,
1083                        pass,
1084                        disposition: SweepDisposition::RemoveFailed,
1085                        age_days,
1086                        owner_root,
1087                        size_bytes,
1088                    });
1089                    continue;
1090                }
1091                let disposition = if is_current_path {
1092                    if let Some(head) = head {
1093                        let _ = write_reusable_sha(&path, &head);
1094                    }
1095                    SweepDisposition::KeptLegacyDeregistered
1096                } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
1097                    tracing::warn!(
1098                        path = %path.display(),
1099                        error = %error,
1100                        "failed to remove released SHA-keyed audit cache",
1101                    );
1102                    SweepDisposition::RemoveFailed
1103                } else {
1104                    SweepDisposition::ReclaimedLegacyRegistered
1105                };
1106                deregistered = true;
1107                disposition
1108            }
1109        };
1110        report.push(SweepEntry {
1111            path,
1112            pass,
1113            disposition,
1114            age_days,
1115            owner_root,
1116            size_bytes,
1117        });
1118    }
1119    if deregistered {
1120        let mut command = Command::new("git");
1121        command
1122            .args(["worktree", "prune", "--expire=now"])
1123            .current_dir(repo_root);
1124        clear_ambient_git_env(&mut command);
1125        let _ = command.output();
1126    }
1127}
1128
1129/// Seed the `.sha` sidecar for a still-registered legacy cache from its HEAD,
1130/// so after deregistration the readiness probe recognizes it as warm. Seeds
1131/// only when the snapshot was raw-materialized and no `.sha` exists yet.
1132fn legacy_reusable_sha(path: &Path) -> Option<String> {
1133    if reusable_worktree_sha_path(path).exists()
1134        || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1135    {
1136        return None;
1137    }
1138    git_rev_parse(path, "HEAD")
1139}
1140
1141/// Enumerate reusable cache DIRECTORY paths for `prefix` by scanning the temp
1142/// dir. Sidecar entries (`.last-used` / `.sha` / `.lock`) are folded back to
1143/// their owning cache path and deduplicated, so a dir removed out from under
1144/// its sidecars is still visited for sidecar-orphan cleanup.
1145fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
1146    let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
1147        return Vec::new();
1148    };
1149    scan_cache_paths_with_hex_suffix(&prefix, scan_root)
1150}
1151
1152fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
1153    let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
1154        return Vec::new();
1155    };
1156    scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
1157}
1158
1159/// Enumerate every reusable cache entry in the temp dir, regardless of repo
1160/// hash, for the cross-repo GC pass. Only names matching the two shapes
1161/// fallow has ever minted are accepted: `<repo16>-<sha16>` (legacy) and
1162/// `<repo16>-root-<root16>` (current). Sidecar entries fold back to their
1163/// owning cache path like the repo-scoped scan.
1164fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
1165    const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
1166
1167    let Ok(entries) = std::fs::read_dir(scan_root) else {
1168        return Vec::new();
1169    };
1170    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1171    let mut paths = Vec::new();
1172    for entry in entries.flatten() {
1173        let name = entry.file_name();
1174        let Some(name) = name.to_str() else {
1175            continue;
1176        };
1177        let cache_name = strip_cache_sidecar_suffix(name);
1178        let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
1179            continue;
1180        };
1181        if !cache_hash_suffix_is_valid(hash_suffix) {
1182            continue;
1183        }
1184        let path = scan_root.join(cache_name);
1185        if seen.insert(path.clone()) {
1186            paths.push(path);
1187        }
1188    }
1189    paths
1190}
1191
1192fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
1193    fn is_hex16(part: &str) -> bool {
1194        part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
1195    }
1196    if let Some((repo, root)) = suffix.split_once("-root-") {
1197        return is_hex16(repo) && is_hex16(root);
1198    }
1199    suffix
1200        .split_once('-')
1201        .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
1202}
1203
1204fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
1205    let Ok(entries) = std::fs::read_dir(scan_root) else {
1206        return Vec::new();
1207    };
1208    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
1209    let mut paths = Vec::new();
1210    for entry in entries.flatten() {
1211        let name = entry.file_name();
1212        let Some(name) = name.to_str() else {
1213            continue;
1214        };
1215        let cache_name = strip_cache_sidecar_suffix(name);
1216        let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
1217            continue;
1218        };
1219        if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1220            continue;
1221        }
1222        let path = scan_root.join(cache_name);
1223        if seen.insert(path.clone()) {
1224            paths.push(path);
1225        }
1226    }
1227    paths
1228}
1229
1230/// Strip a known reusable-cache sidecar suffix so a sidecar entry maps to its
1231/// owning cache directory name. Cache dir names end in a hex SHA prefix and
1232/// never contain these suffixes, so the mapping is unambiguous.
1233fn strip_cache_sidecar_suffix(name: &str) -> &str {
1234    for suffix in [
1235        REUSABLE_LAST_USED_SUFFIX,
1236        REUSABLE_SHA_SUFFIX,
1237        REUSABLE_LOCK_SUFFIX,
1238    ] {
1239        if let Some(stripped) = name.strip_suffix(suffix) {
1240            return stripped;
1241        }
1242    }
1243    name
1244}
1245
1246/// Reclaim a single reusable-cache entry, reporting the per-entry outcome.
1247fn reclaim_reusable_cache_entry(
1248    repo_root: &Path,
1249    path: &Path,
1250    max_age: Option<Duration>,
1251    now: SystemTime,
1252) -> SweepDisposition {
1253    // Sidecar orphan: an external temp-reaper (macOS `$TMPDIR` cleanup,
1254    // container restart, CI cache eviction) removed the cache directory but
1255    // its sidecars survive next to it. Reclaim the leftover `.last-used` /
1256    // `.sha` eagerly, independent of `max_age`, so orphans do not accumulate
1257    // even when age-based GC is disabled (`cacheMaxAgeDays = 0`). This also
1258    // fixes a leak that predates #1815: a manual `git worktree remove` left
1259    // sidecars the old git-scoped sweep could never see.
1260    //
1261    // A probe error here reads as "exists" and falls through to the age
1262    // branch; failing toward keeping the entry is a cheap self-rebuild at
1263    // worst, and the orphan branch re-checks under the lock anyway.
1264    if !path.exists() {
1265        return reclaim_orphan_cache_entry(repo_root, path);
1266    }
1267    let Some(max_age) = max_age else {
1268        return SweepDisposition::KeptAgeGcDisabled;
1269    };
1270    reclaim_aged_cache_entry(repo_root, path, max_age, now)
1271}
1272
1273/// Reclaim the leftover sidecars of a cache directory that was deleted out
1274/// from under them. Lock-guarded with a re-check so a concurrent rebuild that
1275/// recreated the directory is preserved. The `.lock` sidecar is deliberately
1276/// never removed (an unlinked-but-still-flocked inode plus a racer's
1277/// `open(O_CREAT)` would split the lock across two inodes).
1278pub fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> SweepDisposition {
1279    let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1280        return SweepDisposition::SkippedLocked;
1281    };
1282    // Re-check under the lock: a concurrent `reuse_or_create` rebuild may
1283    // have recreated the directory between the existence check and the lock.
1284    if path.exists() {
1285        return SweepDisposition::KeptRecreated;
1286    }
1287    match remove_cache_entry_for_sweep(repo_root, path) {
1288        Ok(CacheRemovalOutcome::Removed) => SweepDisposition::ReclaimedOrphan,
1289        Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1290        Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1291        Err(_) => SweepDisposition::RemoveFailed,
1292    }
1293}
1294
1295/// Reclaim a cache entry whose `.last-used` sidecar is older than `max_age`.
1296/// Seeds a fresh owner-recording sidecar for pre-upgrade entries that lack
1297/// one (grace-kept so they age from real last-use on the next run).
1298/// Removal is directory-and-sidecars only: the entry is unregistered, so no
1299/// `git` subprocess is involved.
1300fn reclaim_aged_cache_entry(
1301    repo_root: &Path,
1302    path: &Path,
1303    max_age: Duration,
1304    now: SystemTime,
1305) -> SweepDisposition {
1306    let Some(mtime) = last_used_mtime(path) else {
1307        record_last_used(path, repo_root);
1308        return SweepDisposition::KeptGraceSeeded;
1309    };
1310    remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1311}
1312
1313/// Reclaim a cache entry that belongs to a DIFFERENT repo hash than the
1314/// sweeping repo (issue #2169).
1315///
1316/// An entry whose recorded owner root still exists on disk is skipped
1317/// unconditionally: that repo's own sweep governs it, so its
1318/// `cacheMaxAgeDays` policy (including `0` = never) cannot be defeated from
1319/// the outside. Entries with a dead owner, or with no recorded owner (pre-
1320/// upgrade or externally created), are abandoned: nothing will ever sweep
1321/// them under their own hash again, so they age out under THIS run's
1322/// threshold, and a probed-dead owner is reclaimed outright when that
1323/// threshold is switched off (`cacheMaxAgeDays = 0`), which is the documented
1324/// contract of `0` and the only thing that keeps abandoned entries from
1325/// accumulating forever. The grace seed for a missing sidecar stays
1326/// mtime-only because the true owner is unknown here.
1327fn reclaim_foreign_cache_entry(
1328    repo_root: &Path,
1329    path: &Path,
1330    max_age: Option<Duration>,
1331    now: SystemTime,
1332) -> SweepDisposition {
1333    // Like the owned pass, a probe error on the entry itself fails toward a
1334    // cheap self-rebuild and the orphan branch re-checks under the lock.
1335    if !path.exists() {
1336        return reclaim_orphan_cache_entry(repo_root, path);
1337    }
1338    let mut owner_missing = false;
1339    if let Some(owner) = read_last_used_owner(path) {
1340        match probe_owner_liveness(&owner) {
1341            OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1342            OwnerLiveness::Unverifiable(kind) => {
1343                return SweepDisposition::KeptOwnerUnverifiable(kind);
1344            }
1345            OwnerLiveness::Dead => owner_missing = true,
1346        }
1347    }
1348    let Some(max_age) = max_age else {
1349        if owner_missing {
1350            return remove_cache_entry_under_lock(
1351                repo_root,
1352                path,
1353                SweepDisposition::ReclaimedOwnerMissing,
1354            );
1355        }
1356        return SweepDisposition::KeptAgeGcDisabled;
1357    };
1358    let Some(mtime) = last_used_mtime(path) else {
1359        touch_last_used(path);
1360        return SweepDisposition::KeptGraceSeeded;
1361    };
1362    remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
1363}
1364
1365enum OwnerLiveness {
1366    Live,
1367    Dead,
1368    Unverifiable(std::io::ErrorKind),
1369}
1370
1371/// NotFound-only deadness probe for a recorded owner root (issue #2221).
1372///
1373/// `std::fs::metadata` follows symlinks, so a dangling-symlink owner root
1374/// resolves to NotFound (dead), matching the previous `Path::exists()`
1375/// behavior. Every other error (EACCES, EIO, ENOTDIR) classifies the owner as
1376/// unverifiable and keeps the entry: a transient probe failure must never let
1377/// this repo's sweep reclaim another live repo's warm cache or defeat its
1378/// `cacheMaxAgeDays: 0` policy. A path below an unmounted mountpoint still
1379/// reads as NotFound, so that case is NOT protected.
1380fn probe_owner_liveness(owner: &Path) -> OwnerLiveness {
1381    match std::fs::metadata(owner) {
1382        Ok(_) => OwnerLiveness::Live,
1383        Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnerLiveness::Dead,
1384        Err(error) => OwnerLiveness::Unverifiable(error.kind()),
1385    }
1386}
1387
1388/// Whether the owner-liveness gate applies to a dry-run classification (it
1389/// only ever applies to the cross-repo pass).
1390#[derive(Clone, Copy, PartialEq, Eq)]
1391enum OwnerGate {
1392    On,
1393    Off,
1394}
1395
1396/// Read-only disposition classifier for [`SweepMode::DryRun`]. Mirrors the
1397/// decision order of [`reclaim_reusable_cache_entry`] /
1398/// [`reclaim_foreign_cache_entry`] exactly, minus locks and mutation, so a
1399/// preview predicts what an apply run would do (modulo concurrent runs, the
1400/// same caveat `audit-cache remove --dry-run` accepts).
1401fn classify_entry_dry_run(
1402    path: &Path,
1403    max_age: Option<Duration>,
1404    now: SystemTime,
1405    owner_gate: OwnerGate,
1406) -> SweepDisposition {
1407    if !path.exists() {
1408        if !reusable_cache_entry_exists(path) {
1409            return SweepDisposition::KeptLockOnly;
1410        }
1411        return classify_would_remove(path, SweepDisposition::ReclaimedOrphan);
1412    }
1413    let mut owner_missing = false;
1414    if owner_gate == OwnerGate::On
1415        && let Some(owner) = read_last_used_owner(path)
1416    {
1417        match probe_owner_liveness(&owner) {
1418            OwnerLiveness::Live => return SweepDisposition::KeptOwnerLive,
1419            OwnerLiveness::Unverifiable(kind) => {
1420                return SweepDisposition::KeptOwnerUnverifiable(kind);
1421            }
1422            OwnerLiveness::Dead => owner_missing = true,
1423        }
1424    }
1425    let Some(max_age) = max_age else {
1426        if owner_missing {
1427            return classify_would_remove(path, SweepDisposition::ReclaimedOwnerMissing);
1428        }
1429        return SweepDisposition::KeptAgeGcDisabled;
1430    };
1431    let Some(mtime) = last_used_mtime(path) else {
1432        return SweepDisposition::KeptGraceSeeded;
1433    };
1434    let Ok(age) = now.duration_since(mtime) else {
1435        return SweepDisposition::KeptFresh;
1436    };
1437    if age < max_age {
1438        return SweepDisposition::KeptFresh;
1439    }
1440    classify_would_remove(path, SweepDisposition::ReclaimedAged)
1441}
1442
1443/// Apply the same up-front ownership classification to a dry-run
1444/// "would remove" verdict that [`remove_cache_entry_for_sweep`] applies to a
1445/// real removal.
1446fn classify_would_remove(path: &Path, removed: SweepDisposition) -> SweepDisposition {
1447    match cache_entry_ownership(path) {
1448        Ok(CacheEntryOwnership::Owned) => removed,
1449        Ok(CacheEntryOwnership::Unowned(_)) => SweepDisposition::KeptNotOwned,
1450        Err(_) => SweepDisposition::RemoveFailed,
1451    }
1452}
1453
1454fn last_used_mtime(path: &Path) -> Option<SystemTime> {
1455    std::fs::metadata(reusable_worktree_last_used_path(path))
1456        .ok()
1457        .and_then(|metadata| metadata.modified().ok())
1458}
1459
1460fn remove_entry_past_max_age(
1461    repo_root: &Path,
1462    path: &Path,
1463    max_age: Duration,
1464    now: SystemTime,
1465    mtime: SystemTime,
1466) -> SweepDisposition {
1467    let Ok(age) = now.duration_since(mtime) else {
1468        return SweepDisposition::KeptFresh;
1469    };
1470    if age < max_age {
1471        return SweepDisposition::KeptFresh;
1472    }
1473    remove_cache_entry_under_lock(repo_root, path, SweepDisposition::ReclaimedAged)
1474}
1475
1476/// Lock, remove, and map the outcome, reporting `removed` on success.
1477fn remove_cache_entry_under_lock(
1478    repo_root: &Path,
1479    path: &Path,
1480    removed: SweepDisposition,
1481) -> SweepDisposition {
1482    let Some(_lock) = ReusableWorktreeLock::try_acquire(path, GC_LOCK_CONTEXT) else {
1483        return SweepDisposition::SkippedLocked;
1484    };
1485    match remove_cache_entry_for_sweep(repo_root, path) {
1486        Ok(CacheRemovalOutcome::Removed) => removed,
1487        Ok(CacheRemovalOutcome::NothingLeft) => SweepDisposition::KeptLockOnly,
1488        Ok(CacheRemovalOutcome::NotOwned) => SweepDisposition::KeptNotOwned,
1489        Err(err) => {
1490            tracing::warn!(
1491                path = %path.display(),
1492                error = %err,
1493                "failed to remove stale reusable audit worktree entry; entry may leak",
1494            );
1495            SweepDisposition::RemoveFailed
1496        }
1497    }
1498}
1499
1500enum CacheRemovalOutcome {
1501    Removed,
1502    /// Nothing but the (never-deleted) `.lock` sidecar was left to remove.
1503    NothingLeft,
1504    /// The Unix uid ownership check rejected the entry; nothing was touched.
1505    NotOwned,
1506}
1507
1508/// Sweep-side removal wrapper: classifies unowned entries up front (a shared
1509/// runner's other-user entries are a kept outcome, not a failure) and reserves
1510/// `Err` for genuine IO errors after the ownership check passed.
1511fn remove_cache_entry_for_sweep(
1512    repo_root: &Path,
1513    path: &Path,
1514) -> std::io::Result<CacheRemovalOutcome> {
1515    if matches!(
1516        cache_entry_ownership(path)?,
1517        CacheEntryOwnership::Unowned(_)
1518    ) {
1519        return Ok(CacheRemovalOutcome::NotOwned);
1520    }
1521    remove_reusable_cache_entry_locked(repo_root, path).map(|removed| {
1522        if removed {
1523            CacheRemovalOutcome::Removed
1524        } else {
1525            CacheRemovalOutcome::NothingLeft
1526        }
1527    })
1528}
1529
1530/// Recursive byte count of the regular files under `root`.
1531///
1532/// A deliberate plain `read_dir` walk: an `ignore`-crate walk would honor the
1533/// `.gitignore` checked out INSIDE the cache snapshot and skip `node_modules`,
1534/// the bulk of the mass being measured. Symlinks are never followed
1535/// (`DirEntry::metadata` does not traverse them), so pnpm stores and
1536/// cross-volume links cannot inflate or loop the walk; per-entry errors are
1537/// tolerated. Returns `None` when `root` itself is absent or unreadable.
1538fn directory_size_bytes(root: &Path) -> Option<u64> {
1539    let root_entries = std::fs::read_dir(root).ok()?;
1540    let mut total: u64 = 0;
1541    let mut stack = vec![root_entries];
1542    while let Some(entries) = stack.pop() {
1543        for entry in entries.flatten() {
1544            let Ok(metadata) = entry.metadata() else {
1545                continue;
1546            };
1547            if metadata.is_dir() {
1548                if let Ok(child) = std::fs::read_dir(entry.path()) {
1549                    stack.push(child);
1550                }
1551            } else if metadata_is_regular_file(&metadata) {
1552                total = total.saturating_add(metadata.len());
1553            }
1554        }
1555    }
1556    Some(total)
1557}
1558
1559/// Measure candidate entry sizes in parallel on the already-configured rayon
1560/// pool. Returns an empty map under [`SweepSizes::Skip`].
1561fn measure_entry_sizes<'a>(
1562    sizes: SweepSizes,
1563    paths: impl Iterator<Item = &'a PathBuf>,
1564) -> FxHashMap<PathBuf, Option<u64>> {
1565    use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
1566
1567    if sizes == SweepSizes::Skip {
1568        return FxHashMap::default();
1569    }
1570    let paths: Vec<&PathBuf> = paths.collect();
1571    paths
1572        .par_iter()
1573        .map(|path| ((*path).clone(), directory_size_bytes(path)))
1574        .collect()
1575}
1576
1577pub fn canonical_root_hash(root: &Path) -> u64 {
1578    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1579    xxh3_64(&path_identity_bytes(&canonical_root))
1580}
1581
1582#[cfg(unix)]
1583fn path_identity_bytes(path: &Path) -> Vec<u8> {
1584    use std::os::unix::ffi::OsStrExt as _;
1585
1586    path.as_os_str().as_bytes().to_vec()
1587}
1588
1589#[cfg(windows)]
1590fn path_identity_bytes(path: &Path) -> Vec<u8> {
1591    use std::os::windows::ffi::OsStrExt as _;
1592
1593    path.as_os_str()
1594        .encode_wide()
1595        .flat_map(u16::to_le_bytes)
1596        .collect()
1597}
1598
1599#[cfg(not(any(unix, windows)))]
1600fn path_identity_bytes(path: &Path) -> Vec<u8> {
1601    path.to_string_lossy().as_bytes().to_vec()
1602}
1603
1604pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
1605    let root_hash = canonical_root_hash(requested_root);
1606    let repo_hash = git_toplevel(requested_root)
1607        .as_deref()
1608        .map_or(root_hash, canonical_root_hash);
1609    std::env::temp_dir().join(format!(
1610        "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
1611    ))
1612}
1613
1614fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1615    let git_root = git_toplevel(requested_root)?;
1616    let repo_hash = canonical_root_hash(&git_root);
1617    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
1618}
1619
1620fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
1621    let git_root = git_toplevel(requested_root)?;
1622    let repo_hash = canonical_root_hash(&git_root);
1623    Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
1624}
1625
1626#[cfg(test)]
1627pub fn legacy_reusable_audit_worktree_path(
1628    requested_root: &Path,
1629    base_sha: &str,
1630) -> Option<PathBuf> {
1631    let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1632    Some(std::env::temp_dir().join(format!(
1633        "{}{sha_prefix}",
1634        legacy_reusable_cache_repo_prefix(requested_root)?
1635    )))
1636}
1637
1638/// Readiness for a reusable cache HIT: the directory exists and its `.sha`
1639/// sidecar records exactly `base_sha`.
1640///
1641/// Fidelity is equivalent to the old in-worktree `git rev-parse HEAD` probe:
1642/// that probe read the host admin dir's HEAD, never the snapshot's on-disk
1643/// content, so neither approach detects content damage. The `.sha` is only
1644/// ever written after a successful materialization + deregistration, so a
1645/// torn snapshot never presents a matching sidecar. On a hit the `.git` stub
1646/// is repaired idempotently so gitignore parity holds even if the stub was
1647/// removed out-of-band.
1648fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
1649    if !reusable_cache_directory_is_trusted(path) {
1650        return false;
1651    }
1652    let recorded = read_reusable_sha(path);
1653    if recorded.as_deref() != Some(base_sha) {
1654        return false;
1655    }
1656    repair_unregistered_git_stub(path)
1657}
1658
1659fn read_reusable_sha(path: &Path) -> Option<String> {
1660    const MAX_SHA_SIDECAR_BYTES: u64 = 129;
1661
1662    let sidecar = reusable_worktree_sha_path(path);
1663    let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
1664    if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
1665        return None;
1666    }
1667    let mut contents = String::new();
1668    std::fs::File::open(sidecar)
1669        .ok()?
1670        .take(MAX_SHA_SIDECAR_BYTES)
1671        .read_to_string(&mut contents)
1672        .ok()?;
1673    Some(contents.trim().to_owned())
1674}
1675
1676#[cfg(unix)]
1677fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1678    use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1679
1680    let Ok(metadata) = std::fs::symlink_metadata(path) else {
1681        return false;
1682    };
1683    metadata.file_type().is_dir()
1684        && metadata.uid() == rustix::process::geteuid().as_raw()
1685        && metadata.permissions().mode().trailing_zeros() >= 6
1686}
1687
1688#[cfg(not(unix))]
1689fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1690    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1691}
1692
1693/// Recover a current-path cache that is still a registered Git worktree.
1694///
1695/// This can occur during mixed-version use or after interruption between
1696/// registration and deregistration. Keep the cache warm only when HEAD matches
1697/// the requested full SHA and raw materialization completed.
1698fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1699    if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1700        return false;
1701    }
1702    let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1703    if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1704    {
1705        return false;
1706    }
1707    if unregister_worktree_checked(repo_root, path).is_err() {
1708        return false;
1709    }
1710    write_reusable_sha(path, base_sha).is_ok()
1711}
1712
1713/// Remove every reusable base-snapshot cache owned by `requested_root`.
1714///
1715/// The root-owned entry and every SHA-suffixed entry from the old
1716/// git-top-level identity are locked independently. Contended entries are
1717/// reported as skipped. Lock files are permanent lock identities and are never
1718/// removed.
1719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1720pub struct AuditCacheRemovalReport {
1721    pub found: usize,
1722    pub removed: usize,
1723    pub skipped: usize,
1724    pub dry_run: bool,
1725}
1726
1727pub fn remove_reusable_audit_caches(
1728    requested_root: &Path,
1729    dry_run: bool,
1730) -> std::io::Result<AuditCacheRemovalReport> {
1731    let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1732    paths.extend(scan_legacy_reusable_cache_paths(
1733        requested_root,
1734        &std::env::temp_dir(),
1735    ));
1736    if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1737        paths.extend(scan_root_owned_cache_paths(requested_root));
1738    }
1739    paths.sort();
1740    paths.dedup();
1741
1742    let mut report = AuditCacheRemovalReport {
1743        found: 0,
1744        removed: 0,
1745        skipped: 0,
1746        dry_run,
1747    };
1748    for path in paths {
1749        if !reusable_cache_entry_exists(&path) {
1750            continue;
1751        }
1752        report.found += 1;
1753        if dry_run {
1754            // A preview must not touch the filesystem. Acquiring the lock would
1755            // create the `.lock` sidecar (create_new) for entries that lack one,
1756            // violating the documented --dry-run contract. Contention is a race
1757            // only the real removal reports.
1758            continue;
1759        }
1760        let Some(_lock) =
1761            ReusableWorktreeLock::try_acquire(&path, "cache removal reports the entry as skipped")
1762        else {
1763            report.skipped += 1;
1764            continue;
1765        };
1766        if remove_reusable_cache_entry_locked(requested_root, &path)? {
1767            report.removed += 1;
1768        }
1769    }
1770    Ok(report)
1771}
1772
1773fn reusable_cache_entry_exists(path: &Path) -> bool {
1774    path_entry_exists(path)
1775        || path_entry_exists(&reusable_worktree_sha_path(path))
1776        || path_entry_exists(&reusable_worktree_last_used_path(path))
1777}
1778
1779/// Like [`reusable_cache_entry_exists`] but also counts a surviving `.lock`
1780/// sidecar, which is deliberately never deleted and marks a real (if fully
1781/// reclaimed) cache identity worth reporting.
1782fn cache_entry_has_presence(path: &Path) -> bool {
1783    reusable_cache_entry_exists(path) || path_entry_exists(&reusable_worktree_lock_path(path))
1784}
1785
1786fn path_entry_exists(path: &Path) -> bool {
1787    std::fs::symlink_metadata(path).is_ok()
1788}
1789
1790/// Remove one reusable cache while its caller holds the entry's exclusive
1791/// lock. Absence is success. The lock sidecar is deliberately preserved.
1792fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1793    let existed = reusable_cache_entry_exists(path);
1794    ensure_cache_entry_is_owned(path)?;
1795    if trusted_worktree_admin_dir(repo_root, path).is_some() {
1796        unregister_worktree_checked(repo_root, path)?;
1797    }
1798    remove_dir_if_exists(path)?;
1799    remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1800    remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1801    Ok(existed)
1802}
1803
1804enum CacheEntryOwnership {
1805    Owned,
1806    #[cfg_attr(
1807        not(unix),
1808        expect(dead_code, reason = "only the Unix uid probe constructs this variant")
1809    )]
1810    Unowned(PathBuf),
1811}
1812
1813#[cfg(unix)]
1814fn cache_entry_ownership(path: &Path) -> std::io::Result<CacheEntryOwnership> {
1815    use std::os::unix::fs::MetadataExt as _;
1816
1817    let effective_uid = rustix::process::geteuid().as_raw();
1818    for entry in [
1819        path.to_path_buf(),
1820        reusable_worktree_sha_path(path),
1821        reusable_worktree_last_used_path(path),
1822    ] {
1823        let metadata = match std::fs::symlink_metadata(&entry) {
1824            Ok(metadata) => metadata,
1825            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1826            Err(error) => return Err(error),
1827        };
1828        if metadata.uid() != effective_uid {
1829            return Ok(CacheEntryOwnership::Unowned(entry));
1830        }
1831    }
1832    Ok(CacheEntryOwnership::Owned)
1833}
1834
1835#[cfg(not(unix))]
1836#[expect(
1837    clippy::unnecessary_wraps,
1838    reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1839)]
1840fn cache_entry_ownership(_path: &Path) -> std::io::Result<CacheEntryOwnership> {
1841    Ok(CacheEntryOwnership::Owned)
1842}
1843
1844fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1845    match cache_entry_ownership(path)? {
1846        CacheEntryOwnership::Owned => Ok(()),
1847        CacheEntryOwnership::Unowned(entry) => Err(std::io::Error::new(
1848            std::io::ErrorKind::PermissionDenied,
1849            format!(
1850                "refusing to remove unowned audit cache entry `{}`",
1851                entry.display()
1852            ),
1853        )),
1854    }
1855}
1856
1857fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1858    match std::fs::remove_dir_all(path) {
1859        Ok(()) => Ok(()),
1860        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1861        Err(err) => Err(err),
1862    }
1863}
1864
1865fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1866    match std::fs::remove_file(path) {
1867        Ok(()) => Ok(()),
1868        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1869        Err(err) => Err(err),
1870    }
1871}
1872
1873/// Deregister a freshly-added audit worktree from git while KEEPING its
1874/// directory on disk (issue #1815).
1875///
1876/// Targets the single admin dir this worktree owns via its `.git` gitfile
1877/// pointer, rather than a global `git worktree prune`, so a user's unrelated
1878/// prunable worktrees are never collaterally deregistered and git's
1879/// name-collision admin suffixing (`<name>1`) is handled for free. The `.git`
1880/// gitfile is REPLACED with an invalid stub (see [`UNREGISTERED_GITDIR_STUB`]),
1881/// never deleted.
1882pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1883    unregister_worktree_checked(repo_root, path)
1884}
1885
1886fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1887    if !path.exists() {
1888        return Ok(());
1889    }
1890    let gitfile = path.join(".git");
1891    let metadata = std::fs::symlink_metadata(&gitfile)?;
1892    if !metadata_is_regular_file(&metadata) {
1893        return Err(std::io::Error::new(
1894            std::io::ErrorKind::InvalidData,
1895            "refusing to deregister through a non-file audit worktree .git entry",
1896        ));
1897    }
1898    let contents = std::fs::read_to_string(&gitfile)?;
1899    if contents == UNREGISTERED_GITDIR_STUB {
1900        return Ok(());
1901    }
1902    let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1903        return Err(std::io::Error::new(
1904            std::io::ErrorKind::InvalidData,
1905            "refusing to deregister an unverified audit worktree admin entry",
1906        ));
1907    };
1908    remove_dir_if_exists(&admin_dir)?;
1909    write_git_stub_safely(&gitfile)
1910}
1911
1912/// Ensure a cache hit's `.git` stub stays valid. Recreates the stub if the
1913/// gitfile is gone (so gitignore parity holds), and deregisters in place if a
1914/// live pointer to a fallow admin dir is found (a mixed-version re-registration
1915/// or a torn transient window).
1916fn repair_unregistered_git_stub(path: &Path) -> bool {
1917    let gitfile = path.join(".git");
1918    let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1919        return write_git_stub_safely(&gitfile).is_ok();
1920    };
1921    if !metadata_is_regular_file(&metadata) {
1922        return false;
1923    }
1924    std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1925}
1926
1927fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1928    let mut options = std::fs::OpenOptions::new();
1929    options.write(true).truncate(true);
1930    match std::fs::symlink_metadata(gitfile) {
1931        Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1932        Ok(_) => {
1933            return Err(std::io::Error::new(
1934                std::io::ErrorKind::InvalidData,
1935                "refusing to replace non-file audit worktree .git entry",
1936            ));
1937        }
1938        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1939            options.create_new(true);
1940        }
1941        Err(error) => return Err(error),
1942    }
1943    let mut file = options.open(gitfile)?;
1944    file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1945    file.sync_all()
1946}
1947
1948fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1949    let gitfile = path.join(".git");
1950    let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1951    if !metadata_is_regular_file(&metadata) {
1952        return None;
1953    }
1954    let contents = std::fs::read_to_string(&gitfile).ok()?;
1955    let admin_dir = parse_worktree_gitdir(&contents)?;
1956    if !is_fallow_admin_dir(&admin_dir) {
1957        return None;
1958    }
1959    let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1960    let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1961    let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1962    if admin_parent != worktrees_dir {
1963        return None;
1964    }
1965    let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1966    let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1967    let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1968    (actual_gitfile == expected_gitfile).then_some(admin_dir)
1969}
1970
1971/// Parse the `gitdir: <path>` pointer from a linked-worktree `.git` gitfile.
1972fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1973    contents
1974        .lines()
1975        .find_map(|line| line.trim().strip_prefix("gitdir:"))
1976        .map(|rest| PathBuf::from(rest.trim()))
1977}
1978
1979/// True when an admin dir basename is one fallow itself created, used as a
1980/// safety belt before removing it.
1981fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1982    admin_dir
1983        .file_name()
1984        .and_then(|name| name.to_str())
1985        .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1986}
1987
1988pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1989    let mut command = Command::new("git");
1990    command.args(["rev-parse", rev]).current_dir(root);
1991    clear_ambient_git_env(&mut command);
1992    let output = command.output().ok()?;
1993    if !output.status.success() {
1994        return None;
1995    }
1996    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1997}
1998
1999pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
2000    let mut command = Command::new("git");
2001    command
2002        .args(["rev-parse", "--show-toplevel"])
2003        .current_dir(root);
2004    clear_ambient_git_env(&mut command);
2005    let output = command.output().ok()?;
2006    if !output.status.success() {
2007        return None;
2008    }
2009    let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
2010    Some(dunce::canonicalize(&path).unwrap_or(path))
2011}
2012
2013fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
2014    let Some(worktrees) = list_audit_worktrees(repo_root) else {
2015        return false;
2016    };
2017    worktrees.iter().any(|worktree| paths_equal(worktree, path))
2018}
2019
2020pub fn paths_equal(left: &Path, right: &Path) -> bool {
2021    if left == right {
2022        return true;
2023    }
2024    match (dunce::canonicalize(left), dunce::canonicalize(right)) {
2025        (Ok(left), Ok(right)) => left == right,
2026        _ => false,
2027    }
2028}
2029
2030pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
2031    let mut command = Command::new("git");
2032    command
2033        .args([
2034            "worktree",
2035            "remove",
2036            "--force",
2037            path.to_string_lossy().as_ref(),
2038        ])
2039        .current_dir(repo_root);
2040    clear_ambient_git_env(&mut command);
2041    match crate::signal::scoped_child::output(&mut command) {
2042        Ok(output) => {
2043            if !output.status.success() && path.exists() {
2044                let stderr = String::from_utf8_lossy(&output.stderr);
2045                tracing::warn!(
2046                    path = %path.display(),
2047                    stderr = %stderr.trim(),
2048                    "git worktree remove failed; the directory remains and may leak",
2049                );
2050            }
2051        }
2052        Err(err) => {
2053            tracing::warn!(
2054                path = %path.display(),
2055                error = %err,
2056                "git worktree remove subprocess failed to spawn",
2057            );
2058        }
2059    }
2060}
2061
2062pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
2063    sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
2064}
2065
2066/// `temp_root` is the directory scanned for unregistered dead-pid worktree
2067/// directories. Production always passes `std::env::temp_dir()`; tests pass a
2068/// private root because a fabricated dead-pid fixture in the SHARED temp dir
2069/// is legitimate prey for any concurrent sweep (a parallel test or a spawned
2070/// fallow binary), which races the fixture's own assertions.
2071pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
2072    // Legacy pass: deregister dead-pid non-reusable worktrees left REGISTERED
2073    // by pre-#1815 fallow (or by a crash in the transient registration
2074    // window). The `--expire=now` prune, retained only here, also sweeps any
2075    // now-dangling admin entry.
2076    if deregister_legacy_orphan_worktrees(repo_root) {
2077        let mut command = Command::new("git");
2078        command
2079            .args(["worktree", "prune", "--expire=now"])
2080            .current_dir(repo_root);
2081        clear_ambient_git_env(&mut command);
2082        let _ = command.output();
2083    }
2084
2085    // Primary pass: remove unregistered dead-pid worktree DIRECTORIES via a
2086    // temp-dir prefix scan. A dead PID means the owning process is gone
2087    // regardless of repo, so this scan is global (not repo-scoped).
2088    for path in scan_non_reusable_orphan_paths(temp_root) {
2089        let _ = std::fs::remove_dir_all(&path);
2090    }
2091}
2092
2093/// Deregister dead-pid non-reusable worktrees left REGISTERED by pre-#1815
2094/// fallow or by a crash mid-registration. Returns `true` when any were removed.
2095fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
2096    let Some(worktrees) = list_audit_worktrees(repo_root) else {
2097        return false;
2098    };
2099    let mut removed_any = false;
2100    for path in worktrees {
2101        if !is_fallow_audit_worktree_path(&path)
2102            || is_reusable_audit_worktree_path(&path)
2103            || audit_worktree_process_is_alive(&path)
2104        {
2105            continue;
2106        }
2107        remove_audit_worktree(repo_root, &path);
2108        let _ = std::fs::remove_dir_all(&path);
2109        removed_any = true;
2110    }
2111    removed_any
2112}
2113
2114/// Enumerate unregistered non-reusable worktree DIRECTORY paths owned by a
2115/// dead PID. Reusable caches yield no parseable PID and are skipped.
2116fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
2117    let Ok(entries) = std::fs::read_dir(temp) else {
2118        return Vec::new();
2119    };
2120    let mut paths = Vec::new();
2121    for entry in entries.flatten() {
2122        let name = entry.file_name();
2123        let Some(name) = name.to_str() else {
2124            continue;
2125        };
2126        let Some(pid) = audit_worktree_pid(name) else {
2127            continue;
2128        };
2129        if process_is_alive(pid) || !entry.path().is_dir() {
2130            continue;
2131        }
2132        paths.push(temp.join(name));
2133    }
2134    paths
2135}
2136
2137pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
2138    let mut command = Command::new("git");
2139    command
2140        .args(["worktree", "list", "--porcelain"])
2141        .current_dir(repo_root);
2142    clear_ambient_git_env(&mut command);
2143    let output = command.output().ok()?;
2144    if !output.status.success() {
2145        return None;
2146    }
2147    Some(parse_worktree_list(&String::from_utf8_lossy(
2148        &output.stdout,
2149    )))
2150}
2151
2152pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
2153    output
2154        .lines()
2155        .filter_map(|line| line.strip_prefix("worktree "))
2156        .map(PathBuf::from)
2157        .filter(|path| is_fallow_audit_worktree_path(path))
2158        .collect()
2159}
2160
2161pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
2162    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2163        return false;
2164    };
2165    name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
2166}
2167
2168pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
2169    path.file_name()
2170        .and_then(|name| name.to_str())
2171        .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
2172}
2173
2174fn path_is_inside_temp_dir(path: &Path) -> bool {
2175    let temp = std::env::temp_dir();
2176    let simple_path = dunce::simplified(path);
2177    let simple_temp = dunce::simplified(&temp);
2178    if simple_path.starts_with(simple_temp) {
2179        return true;
2180    }
2181    let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
2182        return false;
2183    };
2184    let simple_canonical_temp = dunce::simplified(&canonical_temp);
2185    simple_path.starts_with(simple_canonical_temp)
2186        || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
2187            dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
2188        })
2189}
2190
2191fn audit_worktree_process_is_alive(path: &Path) -> bool {
2192    let Some(pid) = path
2193        .file_name()
2194        .and_then(|name| name.to_str())
2195        .and_then(audit_worktree_pid)
2196    else {
2197        return false;
2198    };
2199    process_is_alive(pid)
2200}
2201
2202pub fn audit_worktree_pid(name: &str) -> Option<u32> {
2203    name.strip_prefix("fallow-audit-base-")?
2204        .split('-')
2205        .next()?
2206        .parse()
2207        .ok()
2208}
2209
2210#[cfg(unix)]
2211pub fn process_is_alive(pid: u32) -> bool {
2212    Command::new("kill")
2213        .args(["-0", &pid.to_string()])
2214        .output()
2215        .is_ok_and(|output| output.status.success())
2216}
2217
2218#[cfg(windows)]
2219pub fn process_is_alive(pid: u32) -> bool {
2220    windows_process::is_alive(pid)
2221}
2222
2223#[cfg(not(any(unix, windows)))]
2224pub fn process_is_alive(_pid: u32) -> bool {
2225    true
2226}
2227
2228#[cfg(windows)]
2229#[allow(
2230    unsafe_code,
2231    reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
2232)]
2233mod windows_process {
2234    use windows_sys::Win32::Foundation::{
2235        CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
2236        WAIT_OBJECT_0,
2237    };
2238    use windows_sys::Win32::System::Threading::{
2239        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
2240    };
2241
2242    /// RAII wrapper that calls `CloseHandle` on drop, mirroring `std::mem::drop`
2243    /// semantics for kernel handles. Used so every exit path through
2244    /// `is_alive` releases the handle without manual cleanup.
2245    struct ProcessHandle(HANDLE);
2246
2247    impl Drop for ProcessHandle {
2248        fn drop(&mut self) {
2249            // SAFETY: `self.0` is a non-null handle obtained from a successful
2250            // `OpenProcess` call. We have unique ownership (the value is only
2251            // ever created inside `is_alive`), so this is the sole consumer.
2252            unsafe {
2253                CloseHandle(self.0);
2254            }
2255        }
2256    }
2257
2258    /// Cross-platform PID liveness check for Windows.
2259    ///
2260    /// Mirrors `kill -0 $pid` semantics: returns `true` when the process is
2261    /// running OR when we cannot prove it dead (e.g., `ERROR_ACCESS_DENIED` on
2262    /// processes owned by another session). Returns `false` only when the PID
2263    /// definitively does not exist (`ERROR_INVALID_PARAMETER`) or the wait
2264    /// reports the process has exited.
2265    pub fn is_alive(pid: u32) -> bool {
2266        // SAFETY: `OpenProcess` accepts any `u32` PID; it either returns a
2267        // non-null handle we own, or null on failure with `GetLastError`
2268        // describing why. No memory is borrowed across the FFI boundary.
2269        let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
2270        if raw.is_null() {
2271            // SAFETY: `GetLastError` reads thread-local storage set by the
2272            // failing `OpenProcess` call. It has no preconditions.
2273            let err = unsafe { GetLastError() };
2274            #[expect(
2275                clippy::match_same_arms,
2276                reason = "named arm documents the cross-session case"
2277            )]
2278            return match err {
2279                ERROR_INVALID_PARAMETER => false,
2280                ERROR_ACCESS_DENIED => true,
2281                _ => true,
2282            };
2283        }
2284        let handle = ProcessHandle(raw);
2285        // SAFETY: `handle.0` is non-null (checked above) and owned by the
2286        // `ProcessHandle` RAII wrapper.
2287        let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
2288        wait_result != WAIT_OBJECT_0
2289    }
2290}
2291
2292impl Drop for BaseWorktree {
2293    fn drop(&mut self) {
2294        if self.persistent {
2295            return;
2296        }
2297        // The non-reusable worktree was deregistered right after creation, so
2298        // cleanup is a plain directory removal: no `git` subprocess runs, and
2299        // a SIGKILL before this Drop can never leave an admin entry behind.
2300        let _ = std::fs::remove_dir_all(&self.path);
2301    }
2302}
2303
2304#[cfg(test)]
2305mod tests {
2306    use super::*;
2307
2308    /// Many threads minting a non-reusable worktree path at the same instant
2309    /// must each get a distinct path. Before the monotonic counter, concurrent
2310    /// callers read the same non-monotonic `nanos` and collided, so two parallel
2311    /// `audit` runs in one process raced on `git worktree add` and one aborted
2312    /// with a generic error (a flaky exit 2 under parallel unit tests).
2313    #[test]
2314    fn non_reusable_worktree_paths_are_unique_under_concurrency() {
2315        const N: usize = 64;
2316        let barrier = std::sync::Barrier::new(N);
2317        let paths = std::sync::Mutex::new(Vec::with_capacity(N));
2318        std::thread::scope(|s| {
2319            for _ in 0..N {
2320                let barrier = &barrier;
2321                let paths = &paths;
2322                s.spawn(move || {
2323                    barrier.wait();
2324                    let path = non_reusable_worktree_path().expect("path should build");
2325                    paths.lock().unwrap().push(path);
2326                });
2327            }
2328        });
2329        let mut paths = paths.into_inner().unwrap();
2330        assert_eq!(paths.len(), N);
2331        paths.sort();
2332        paths.dedup();
2333        assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
2334    }
2335
2336    /// The pid stays the first segment so orphan-sweep parsing keeps working.
2337    #[test]
2338    fn non_reusable_worktree_path_pid_is_parseable() {
2339        let path = non_reusable_worktree_path().expect("path should build");
2340        let name = path.file_name().unwrap().to_str().unwrap();
2341        assert!(is_fallow_audit_worktree_path(&path));
2342        assert!(!is_reusable_audit_worktree_path(&path));
2343        assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
2344    }
2345
2346    #[test]
2347    fn directory_size_bytes_sums_regular_files_recursively() {
2348        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2349        let root = temp.path().join("cache");
2350        std::fs::create_dir_all(root.join("node_modules/dep")).expect("tree should be created");
2351        std::fs::write(root.join("a.txt"), vec![0u8; 10]).expect("file should be written");
2352        std::fs::write(root.join("node_modules/dep/b.js"), vec![0u8; 32])
2353            .expect("nested file should be written");
2354        // A gitignore inside the snapshot must NOT hide the mass being
2355        // measured (the walk is deliberately ignore-crate-free).
2356        std::fs::write(root.join(".gitignore"), "node_modules\n")
2357            .expect("gitignore should be written");
2358
2359        let size = directory_size_bytes(&root).expect("size walk should succeed");
2360        assert_eq!(size, 10 + 32 + "node_modules\n".len() as u64);
2361        assert_eq!(
2362            directory_size_bytes(&temp.path().join("missing")),
2363            None,
2364            "an absent directory reports no size",
2365        );
2366    }
2367
2368    #[cfg(unix)]
2369    #[test]
2370    fn directory_size_bytes_never_follows_symlinks() {
2371        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2372        let root = temp.path().join("cache");
2373        let outside = temp.path().join("outside");
2374        std::fs::create_dir_all(&root).expect("root should be created");
2375        std::fs::create_dir_all(&outside).expect("outside dir should be created");
2376        std::fs::write(outside.join("big.bin"), vec![0u8; 4096])
2377            .expect("outside file should be written");
2378        std::os::unix::fs::symlink(&outside, root.join("link-dir"))
2379            .expect("dir symlink should be created");
2380        std::os::unix::fs::symlink(outside.join("big.bin"), root.join("link-file"))
2381            .expect("file symlink should be created");
2382
2383        assert_eq!(
2384            directory_size_bytes(&root),
2385            Some(0),
2386            "symlinked directories and files must not be traversed or counted",
2387        );
2388    }
2389
2390    #[cfg(unix)]
2391    #[test]
2392    fn cache_sidecar_open_does_not_follow_symlinks() {
2393        let temp = tempfile::TempDir::new().expect("temp dir should be created");
2394        let victim = temp.path().join("victim");
2395        let sidecar = temp.path().join("cache.lock");
2396        std::fs::write(&victim, "unchanged\n").expect("victim should be written");
2397        std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
2398
2399        assert!(open_or_create_owned_sidecar(&sidecar).is_err());
2400        assert_eq!(
2401            std::fs::read_to_string(victim).expect("victim should remain readable"),
2402            "unchanged\n",
2403        );
2404    }
2405}