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::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 pub fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
22 sweep_orphan_audit_worktrees(repo_root);
23 if let Some(base_sha) = base_sha
24 && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
25 {
26 return Some(worktree);
27 }
28 let path = non_reusable_worktree_path()?;
29 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
30 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
31 repo_root,
32 guard.path(),
33 base_ref,
34 ) {
35 tracing::debug!(
36 base_ref,
37 error = %error,
38 "could not materialize non-reusable audit base worktree",
39 );
40 return None;
41 }
42 if let Err(error) = unregister_worktree(repo_root, guard.path()) {
47 tracing::debug!(
48 path = %guard.path().display(),
49 error = %error,
50 "could not deregister non-reusable audit base worktree",
51 );
52 return None;
53 }
54 guard.defuse();
55 drop(guard);
56 let worktree = Self {
57 path,
58 persistent: false,
59 _reusable_lock: None,
60 };
61 materialize_base_dependency_context(repo_root, worktree.path());
62 Some(worktree)
63 }
64
65 pub fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
66 let path = reusable_audit_worktree_path(repo_root);
67 let reusable_lock = ReusableWorktreeLock::try_acquire(&path)?;
68
69 if reusable_audit_worktree_is_ready(&path, base_sha)
70 || try_migrate_registered_current_cache(repo_root, &path, base_sha)
71 {
72 let worktree = Self {
73 path,
74 persistent: true,
75 _reusable_lock: Some(reusable_lock),
76 };
77 materialize_base_dependency_context(repo_root, worktree.path());
78 touch_last_used(worktree.path());
79 return Some(worktree);
80 }
81
82 if let Err(error) = remove_file_if_exists(&reusable_worktree_sha_path(&path)) {
83 tracing::debug!(
84 path = %path.display(),
85 error = %error,
86 "could not clear reusable audit worktree readiness before rebuild",
87 );
88 return None;
89 }
90 if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
91 tracing::debug!(
92 path = %path.display(),
93 error = %error,
94 "could not remove stale reusable audit worktree before rebuild",
95 );
96 return None;
97 }
98 let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
99 if let Err(error) = fallow_engine::repo_refs::create_detached_base_worktree(
100 repo_root,
101 guard.path(),
102 base_sha,
103 ) {
104 tracing::debug!(
105 base_sha,
106 error = %error,
107 "could not materialize reusable audit base worktree",
108 );
109 return None;
110 }
111 if let Err(error) = unregister_worktree_checked(repo_root, guard.path()) {
116 tracing::debug!(
117 path = %guard.path().display(),
118 error = %error,
119 "could not deregister reusable audit base worktree",
120 );
121 return None;
122 }
123 guard.defuse();
124 drop(guard);
125 let readiness_published = write_reusable_sha(&path, base_sha).is_ok();
126
127 let worktree = Self {
128 path,
129 persistent: true,
130 _reusable_lock: Some(reusable_lock),
131 };
132 materialize_base_dependency_context(repo_root, worktree.path());
133 if readiness_published {
134 touch_last_used(worktree.path());
135 }
136 Some(worktree)
137 }
138
139 pub fn path(&self) -> &Path {
140 &self.path
141 }
142}
143
144fn non_reusable_worktree_path() -> Option<PathBuf> {
154 static SEQ: AtomicU64 = AtomicU64::new(0);
155 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
156 let nanos = SystemTime::now()
157 .duration_since(SystemTime::UNIX_EPOCH)
158 .ok()?
159 .as_nanos();
160 Some(std::env::temp_dir().join(format!(
161 "fallow-audit-base-{}-{nanos}-{seq}",
162 std::process::id()
163 )))
164}
165
166pub struct WorktreeCleanupGuard<'a> {
178 repo_root: PathBuf,
179 path: &'a Path,
180 armed: bool,
181}
182
183impl<'a> WorktreeCleanupGuard<'a> {
184 pub fn new(repo_root: &Path, path: &'a Path) -> Self {
185 Self {
186 repo_root: repo_root.to_path_buf(),
187 path,
188 armed: true,
189 }
190 }
191
192 pub fn path(&self) -> &Path {
193 self.path
194 }
195
196 pub fn defuse(&mut self) {
199 self.armed = false;
200 }
201}
202
203impl Drop for WorktreeCleanupGuard<'_> {
204 fn drop(&mut self) {
205 if self.armed {
206 remove_audit_worktree(&self.repo_root, self.path);
207 let _ = std::fs::remove_dir_all(self.path);
208 }
209 }
210}
211
212pub struct ReusableWorktreeLock {
218 file: std::fs::File,
219}
220
221impl ReusableWorktreeLock {
222 pub fn try_acquire(reusable_path: &Path) -> Option<Self> {
223 let lock_path = reusable_worktree_lock_path(reusable_path);
224 let file = open_or_create_owned_sidecar(&lock_path).ok()?;
225 match file.try_lock() {
226 Ok(()) => Some(Self { file }),
227 Err(std::fs::TryLockError::WouldBlock) => {
228 tracing::debug!(
229 path = %lock_path.display(),
230 "reusable audit worktree lock contended; falling back to non-reusable worktree",
231 );
232 None
233 }
234 Err(std::fs::TryLockError::Error(err)) => {
235 tracing::debug!(
236 path = %lock_path.display(),
237 error = %err,
238 "could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
239 );
240 None
241 }
242 }
243 }
244}
245
246impl Drop for ReusableWorktreeLock {
247 fn drop(&mut self) {
248 let _ = self.file.unlock();
249 }
250}
251
252pub fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
253 sidecar_path(reusable_path, REUSABLE_LOCK_SUFFIX)
254}
255
256fn sidecar_path(reusable_path: &Path, suffix: &str) -> PathBuf {
260 let mut name = reusable_path
261 .file_name()
262 .map(std::ffi::OsString::from)
263 .unwrap_or_default();
264 name.push(suffix);
265 reusable_path
266 .parent()
267 .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
268}
269
270pub fn reusable_worktree_sha_path(reusable_path: &Path) -> PathBuf {
274 sidecar_path(reusable_path, REUSABLE_SHA_SUFFIX)
275}
276
277fn write_reusable_sha(reusable_path: &Path, base_sha: &str) -> std::io::Result<()> {
280 static SEQ: AtomicU64 = AtomicU64::new(0);
281
282 let sha_path = reusable_worktree_sha_path(reusable_path);
283 let sequence = SEQ.fetch_add(1, Ordering::Relaxed);
284 let temp_path = sidecar_path(
285 reusable_path,
286 &format!(
287 "{REUSABLE_SHA_SUFFIX}.tmp-{}-{sequence}",
288 std::process::id()
289 ),
290 );
291 let result = (|| {
292 let mut options = std::fs::OpenOptions::new();
293 options.create_new(true).write(true);
294 #[cfg(unix)]
295 {
296 use std::os::unix::fs::OpenOptionsExt as _;
297 options.mode(0o600);
298 }
299 let mut file = options.open(&temp_path)?;
300 file.write_all(format!("{base_sha}\n").as_bytes())?;
301 file.sync_all()?;
302 std::fs::rename(&temp_path, &sha_path)
303 })();
304 if let Err(err) = &result {
305 let _ = std::fs::remove_file(&temp_path);
306 tracing::debug!(
307 path = %sha_path.display(),
308 error = %err,
309 "failed to write reusable audit worktree .sha sidecar; next run will rebuild",
310 );
311 }
312 result
313}
314
315const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
317
318const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
320
321const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
323
324const REUSABLE_SHA_SUFFIX: &str = ".sha";
326
327const REUSABLE_LOCK_SUFFIX: &str = ".lock";
329
330const UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
341
342pub fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
347 sidecar_path(reusable_path, REUSABLE_LAST_USED_SUFFIX)
348}
349
350pub fn touch_last_used(reusable_path: &Path) {
358 let last_used = reusable_worktree_last_used_path(reusable_path);
359 let result = open_or_create_owned_sidecar(&last_used)
360 .and_then(|file| file.set_modified(SystemTime::now()));
361 if let Err(err) = result {
362 tracing::warn!(
363 path = %last_used.display(),
364 error = %err,
365 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
366 );
367 }
368}
369
370fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
371 match std::fs::symlink_metadata(path) {
372 Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
373 std::fs::OpenOptions::new().write(true).open(path)
374 }
375 Ok(_) => Err(std::io::Error::new(
376 std::io::ErrorKind::PermissionDenied,
377 "refusing to open an untrusted audit cache sidecar",
378 )),
379 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
380 let mut options = std::fs::OpenOptions::new();
381 options.create_new(true).write(true);
382 #[cfg(unix)]
383 {
384 use std::os::unix::fs::OpenOptionsExt as _;
385 options.mode(0o600);
386 }
387 options.open(path)
388 }
389 Err(error) => Err(error),
390 }
391}
392
393#[cfg(unix)]
394fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
395 use std::os::unix::fs::MetadataExt as _;
396
397 metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
398}
399
400#[cfg(not(unix))]
401fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
402 metadata_is_regular_file(metadata)
403}
404
405#[expect(
406 clippy::filetype_is_file,
407 reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
408)]
409fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
410 metadata.file_type().is_file()
411}
412
413pub fn resolve_cache_max_age_with_options(
420 root: &Path,
421 config_path: Option<&PathBuf>,
422 allow_remote_extends: bool,
423) -> Option<Duration> {
424 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
425 if let Ok(days) = raw.trim().parse::<u32>() {
426 return days_to_duration(days);
427 }
428 tracing::debug!(
429 value = %raw,
430 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
431 );
432 }
433 if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
434 .and_then(|c| c.cache_max_age_days)
435 {
436 return days_to_duration(days);
437 }
438 days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
439}
440
441pub fn days_to_duration(days: u32) -> Option<Duration> {
442 if days == 0 {
443 return None;
444 }
445 Some(Duration::from_secs(u64::from(days) * 86_400))
446}
447
448fn load_audit_config(
452 root: &Path,
453 config_path: Option<&PathBuf>,
454 allow_remote_extends: bool,
455) -> Option<fallow_config::AuditConfig> {
456 let options = fallow_config::ConfigLoadOptions {
457 allow_remote_extends,
458 };
459 if let Some(path) = config_path {
460 return fallow_config::FallowConfig::load_with_options(path, options)
461 .ok()
462 .map(|config| config.audit);
463 }
464 fallow_config::FallowConfig::find_and_load_with_options(root, options)
465 .ok()
466 .flatten()
467 .map(|(config, _path)| config.audit)
468}
469
470pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
501 if deregister_legacy_reusable_caches(repo_root) {
507 let mut command = Command::new("git");
508 command
509 .args(["worktree", "prune", "--expire=now"])
510 .current_dir(repo_root);
511 clear_ambient_git_env(&mut command);
512 let _ = command.output();
513 }
514
515 let mut paths = vec![reusable_audit_worktree_path(repo_root)];
519 paths.extend(scan_legacy_reusable_cache_paths(repo_root));
520 paths.sort();
521 paths.dedup();
522 let now = SystemTime::now();
523 let mut removed: u32 = 0;
524 for path in paths {
525 if reclaim_reusable_cache_entry(repo_root, &path, max_age, now) {
526 removed += 1;
527 }
528 }
529 if removed == 0 {
530 return;
531 }
532 tracing::info!(
533 count = removed,
534 "reclaimed stale audit base-snapshot caches",
535 );
536 if !quiet {
537 let s = plural(removed as usize);
538 let _ = writeln!(
539 std::io::stderr(),
540 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
541 );
542 }
543}
544
545fn deregister_legacy_reusable_caches(repo_root: &Path) -> bool {
550 let Some(worktrees) = list_audit_worktrees(repo_root) else {
551 return false;
552 };
553 let mut deregistered = false;
554 for path in worktrees {
555 if !is_reusable_audit_worktree_path(&path) {
556 continue;
557 }
558 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
559 continue;
560 };
561 if !audit_worktree_is_registered(repo_root, &path) {
562 continue;
563 }
564 let is_current_path = paths_equal(&path, &reusable_audit_worktree_path(repo_root));
565 let head = is_current_path
566 .then(|| legacy_reusable_sha(&path))
567 .flatten();
568 if unregister_worktree_checked(repo_root, &path).is_err() {
569 continue;
570 }
571 if is_current_path {
572 if let Some(head) = head {
573 let _ = write_reusable_sha(&path, &head);
574 }
575 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
576 tracing::warn!(
577 path = %path.display(),
578 error = %error,
579 "failed to remove released SHA-keyed audit cache",
580 );
581 }
582 deregistered = true;
583 }
584 deregistered
585}
586
587fn legacy_reusable_sha(path: &Path) -> Option<String> {
591 if reusable_worktree_sha_path(path).exists()
592 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
593 {
594 return None;
595 }
596 git_rev_parse(path, "HEAD")
597}
598
599fn scan_legacy_reusable_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
604 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
605 return Vec::new();
606 };
607 scan_cache_paths_with_hex_suffix(&prefix)
608}
609
610fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
611 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
612 return Vec::new();
613 };
614 scan_cache_paths_with_hex_suffix(&prefix)
615}
616
617fn scan_cache_paths_with_hex_suffix(prefix: &str) -> Vec<PathBuf> {
618 let temp = std::env::temp_dir();
619 let Ok(entries) = std::fs::read_dir(&temp) else {
620 return Vec::new();
621 };
622 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
623 let mut paths = Vec::new();
624 for entry in entries.flatten() {
625 let name = entry.file_name();
626 let Some(name) = name.to_str() else {
627 continue;
628 };
629 let cache_name = strip_cache_sidecar_suffix(name);
630 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
631 continue;
632 };
633 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
634 continue;
635 }
636 let path = temp.join(cache_name);
637 if seen.insert(path.clone()) {
638 paths.push(path);
639 }
640 }
641 paths
642}
643
644fn strip_cache_sidecar_suffix(name: &str) -> &str {
648 for suffix in [
649 REUSABLE_LAST_USED_SUFFIX,
650 REUSABLE_SHA_SUFFIX,
651 REUSABLE_LOCK_SUFFIX,
652 ] {
653 if let Some(stripped) = name.strip_suffix(suffix) {
654 return stripped;
655 }
656 }
657 name
658}
659
660fn reclaim_reusable_cache_entry(
663 repo_root: &Path,
664 path: &Path,
665 max_age: Option<Duration>,
666 now: SystemTime,
667) -> bool {
668 if !path.exists() {
676 return reclaim_orphan_cache_entry(repo_root, path);
677 }
678 let Some(max_age) = max_age else {
679 return false;
680 };
681 reclaim_aged_cache_entry(repo_root, path, max_age, now)
682}
683
684fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> bool {
690 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
691 return false;
692 };
693 if path.exists() {
696 return false;
697 }
698 remove_reusable_cache_entry_locked(repo_root, path).unwrap_or(false)
699}
700
701fn reclaim_aged_cache_entry(
707 repo_root: &Path,
708 path: &Path,
709 max_age: Duration,
710 now: SystemTime,
711) -> bool {
712 let sidecar = reusable_worktree_last_used_path(path);
713 let sidecar_mtime = std::fs::metadata(&sidecar)
714 .ok()
715 .and_then(|m| m.modified().ok());
716 let Some(mtime) = sidecar_mtime else {
717 touch_last_used(path);
718 return false;
719 };
720 let Ok(age) = now.duration_since(mtime) else {
721 return false;
722 };
723 if age < max_age {
724 return false;
725 }
726 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
727 return false;
728 };
729 match remove_reusable_cache_entry_locked(repo_root, path) {
730 Ok(removed) => removed,
731 Err(err) => {
732 tracing::warn!(
733 path = %path.display(),
734 error = %err,
735 "failed to remove stale reusable audit worktree entry; entry may leak",
736 );
737 false
738 }
739 }
740}
741
742pub fn canonical_root_hash(root: &Path) -> u64 {
743 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
744 xxh3_64(&path_identity_bytes(&canonical_root))
745}
746
747#[cfg(unix)]
748fn path_identity_bytes(path: &Path) -> Vec<u8> {
749 use std::os::unix::ffi::OsStrExt as _;
750
751 path.as_os_str().as_bytes().to_vec()
752}
753
754#[cfg(windows)]
755fn path_identity_bytes(path: &Path) -> Vec<u8> {
756 use std::os::windows::ffi::OsStrExt as _;
757
758 path.as_os_str()
759 .encode_wide()
760 .flat_map(u16::to_le_bytes)
761 .collect()
762}
763
764#[cfg(not(any(unix, windows)))]
765fn path_identity_bytes(path: &Path) -> Vec<u8> {
766 path.to_string_lossy().as_bytes().to_vec()
767}
768
769pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
770 let root_hash = canonical_root_hash(requested_root);
771 let repo_hash = git_toplevel(requested_root)
772 .as_deref()
773 .map_or(root_hash, canonical_root_hash);
774 std::env::temp_dir().join(format!(
775 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
776 ))
777}
778
779fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
780 let git_root = git_toplevel(requested_root)?;
781 let repo_hash = canonical_root_hash(&git_root);
782 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
783}
784
785fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
786 let git_root = git_toplevel(requested_root)?;
787 let repo_hash = canonical_root_hash(&git_root);
788 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
789}
790
791#[cfg(test)]
792pub fn legacy_reusable_audit_worktree_path(
793 requested_root: &Path,
794 base_sha: &str,
795) -> Option<PathBuf> {
796 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
797 Some(std::env::temp_dir().join(format!(
798 "{}{sha_prefix}",
799 legacy_reusable_cache_repo_prefix(requested_root)?
800 )))
801}
802
803fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
814 if !reusable_cache_directory_is_trusted(path) {
815 return false;
816 }
817 let recorded = read_reusable_sha(path);
818 if recorded.as_deref() != Some(base_sha) {
819 return false;
820 }
821 repair_unregistered_git_stub(path)
822}
823
824fn read_reusable_sha(path: &Path) -> Option<String> {
825 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
826
827 let sidecar = reusable_worktree_sha_path(path);
828 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
829 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
830 return None;
831 }
832 let mut contents = String::new();
833 std::fs::File::open(sidecar)
834 .ok()?
835 .take(MAX_SHA_SIDECAR_BYTES)
836 .read_to_string(&mut contents)
837 .ok()?;
838 Some(contents.trim().to_owned())
839}
840
841#[cfg(unix)]
842fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
843 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
844
845 let Ok(metadata) = std::fs::symlink_metadata(path) else {
846 return false;
847 };
848 metadata.file_type().is_dir()
849 && metadata.uid() == rustix::process::geteuid().as_raw()
850 && metadata.permissions().mode().trailing_zeros() >= 6
851}
852
853#[cfg(not(unix))]
854fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
855 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
856}
857
858fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
864 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
865 return false;
866 }
867 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
868 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
869 {
870 return false;
871 }
872 if unregister_worktree_checked(repo_root, path).is_err() {
873 return false;
874 }
875 write_reusable_sha(path, base_sha).is_ok()
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
885pub struct AuditCacheRemovalReport {
886 pub found: usize,
887 pub removed: usize,
888 pub skipped: usize,
889 pub dry_run: bool,
890}
891
892pub fn remove_reusable_audit_caches(
893 requested_root: &Path,
894 dry_run: bool,
895) -> std::io::Result<AuditCacheRemovalReport> {
896 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
897 paths.extend(scan_legacy_reusable_cache_paths(requested_root));
898 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
899 paths.extend(scan_root_owned_cache_paths(requested_root));
900 }
901 paths.sort();
902 paths.dedup();
903
904 let mut report = AuditCacheRemovalReport {
905 found: 0,
906 removed: 0,
907 skipped: 0,
908 dry_run,
909 };
910 for path in paths {
911 if !reusable_cache_entry_exists(&path) {
912 continue;
913 }
914 report.found += 1;
915 if dry_run {
916 continue;
921 }
922 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
923 report.skipped += 1;
924 continue;
925 };
926 if remove_reusable_cache_entry_locked(requested_root, &path)? {
927 report.removed += 1;
928 }
929 }
930 Ok(report)
931}
932
933fn reusable_cache_entry_exists(path: &Path) -> bool {
934 path_entry_exists(path)
935 || path_entry_exists(&reusable_worktree_sha_path(path))
936 || path_entry_exists(&reusable_worktree_last_used_path(path))
937}
938
939fn path_entry_exists(path: &Path) -> bool {
940 std::fs::symlink_metadata(path).is_ok()
941}
942
943fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
946 let existed = reusable_cache_entry_exists(path);
947 ensure_cache_entry_is_owned(path)?;
948 if trusted_worktree_admin_dir(repo_root, path).is_some() {
949 unregister_worktree_checked(repo_root, path)?;
950 }
951 remove_dir_if_exists(path)?;
952 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
953 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
954 Ok(existed)
955}
956
957#[cfg(unix)]
958fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
959 use std::os::unix::fs::MetadataExt as _;
960
961 let effective_uid = rustix::process::geteuid().as_raw();
962 for entry in [
963 path.to_path_buf(),
964 reusable_worktree_sha_path(path),
965 reusable_worktree_last_used_path(path),
966 ] {
967 let metadata = match std::fs::symlink_metadata(&entry) {
968 Ok(metadata) => metadata,
969 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
970 Err(error) => return Err(error),
971 };
972 if metadata.uid() != effective_uid {
973 return Err(std::io::Error::new(
974 std::io::ErrorKind::PermissionDenied,
975 format!(
976 "refusing to remove unowned audit cache entry `{}`",
977 entry.display()
978 ),
979 ));
980 }
981 }
982 Ok(())
983}
984
985#[cfg(not(unix))]
986#[expect(
987 clippy::unnecessary_wraps,
988 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
989)]
990fn ensure_cache_entry_is_owned(_path: &Path) -> std::io::Result<()> {
991 Ok(())
992}
993
994fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
995 match std::fs::remove_dir_all(path) {
996 Ok(()) => Ok(()),
997 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
998 Err(err) => Err(err),
999 }
1000}
1001
1002fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1003 match std::fs::remove_file(path) {
1004 Ok(()) => Ok(()),
1005 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1006 Err(err) => Err(err),
1007 }
1008}
1009
1010pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1020 unregister_worktree_checked(repo_root, path)
1021}
1022
1023fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1024 if !path.exists() {
1025 return Ok(());
1026 }
1027 let gitfile = path.join(".git");
1028 let metadata = std::fs::symlink_metadata(&gitfile)?;
1029 if !metadata_is_regular_file(&metadata) {
1030 return Err(std::io::Error::new(
1031 std::io::ErrorKind::InvalidData,
1032 "refusing to deregister through a non-file audit worktree .git entry",
1033 ));
1034 }
1035 let contents = std::fs::read_to_string(&gitfile)?;
1036 if contents == UNREGISTERED_GITDIR_STUB {
1037 return Ok(());
1038 }
1039 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1040 return Err(std::io::Error::new(
1041 std::io::ErrorKind::InvalidData,
1042 "refusing to deregister an unverified audit worktree admin entry",
1043 ));
1044 };
1045 remove_dir_if_exists(&admin_dir)?;
1046 write_git_stub_safely(&gitfile)
1047}
1048
1049fn repair_unregistered_git_stub(path: &Path) -> bool {
1054 let gitfile = path.join(".git");
1055 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1056 return write_git_stub_safely(&gitfile).is_ok();
1057 };
1058 if !metadata_is_regular_file(&metadata) {
1059 return false;
1060 }
1061 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1062}
1063
1064fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1065 let mut options = std::fs::OpenOptions::new();
1066 options.write(true).truncate(true);
1067 match std::fs::symlink_metadata(gitfile) {
1068 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1069 Ok(_) => {
1070 return Err(std::io::Error::new(
1071 std::io::ErrorKind::InvalidData,
1072 "refusing to replace non-file audit worktree .git entry",
1073 ));
1074 }
1075 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1076 options.create_new(true);
1077 }
1078 Err(error) => return Err(error),
1079 }
1080 let mut file = options.open(gitfile)?;
1081 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1082 file.sync_all()
1083}
1084
1085fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1086 let gitfile = path.join(".git");
1087 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1088 if !metadata_is_regular_file(&metadata) {
1089 return None;
1090 }
1091 let contents = std::fs::read_to_string(&gitfile).ok()?;
1092 let admin_dir = parse_worktree_gitdir(&contents)?;
1093 if !is_fallow_admin_dir(&admin_dir) {
1094 return None;
1095 }
1096 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1097 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1098 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1099 if admin_parent != worktrees_dir {
1100 return None;
1101 }
1102 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1103 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1104 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1105 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1106}
1107
1108fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1110 contents
1111 .lines()
1112 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1113 .map(|rest| PathBuf::from(rest.trim()))
1114}
1115
1116fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1119 admin_dir
1120 .file_name()
1121 .and_then(|name| name.to_str())
1122 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1123}
1124
1125pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1126 let mut command = Command::new("git");
1127 command.args(["rev-parse", rev]).current_dir(root);
1128 clear_ambient_git_env(&mut command);
1129 let output = command.output().ok()?;
1130 if !output.status.success() {
1131 return None;
1132 }
1133 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1134}
1135
1136pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1137 let mut command = Command::new("git");
1138 command
1139 .args(["rev-parse", "--show-toplevel"])
1140 .current_dir(root);
1141 clear_ambient_git_env(&mut command);
1142 let output = command.output().ok()?;
1143 if !output.status.success() {
1144 return None;
1145 }
1146 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1147 Some(dunce::canonicalize(&path).unwrap_or(path))
1148}
1149
1150fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1151 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1152 return false;
1153 };
1154 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1155}
1156
1157pub fn paths_equal(left: &Path, right: &Path) -> bool {
1158 if left == right {
1159 return true;
1160 }
1161 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1162 (Ok(left), Ok(right)) => left == right,
1163 _ => false,
1164 }
1165}
1166
1167pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1168 let mut command = Command::new("git");
1169 command
1170 .args([
1171 "worktree",
1172 "remove",
1173 "--force",
1174 path.to_string_lossy().as_ref(),
1175 ])
1176 .current_dir(repo_root);
1177 clear_ambient_git_env(&mut command);
1178 match crate::signal::scoped_child::output(&mut command) {
1179 Ok(output) => {
1180 if !output.status.success() && path.exists() {
1181 let stderr = String::from_utf8_lossy(&output.stderr);
1182 tracing::warn!(
1183 path = %path.display(),
1184 stderr = %stderr.trim(),
1185 "git worktree remove failed; the directory remains and may leak",
1186 );
1187 }
1188 }
1189 Err(err) => {
1190 tracing::warn!(
1191 path = %path.display(),
1192 error = %err,
1193 "git worktree remove subprocess failed to spawn",
1194 );
1195 }
1196 }
1197}
1198
1199pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1200 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
1201}
1202
1203pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
1209 if deregister_legacy_orphan_worktrees(repo_root) {
1214 let mut command = Command::new("git");
1215 command
1216 .args(["worktree", "prune", "--expire=now"])
1217 .current_dir(repo_root);
1218 clear_ambient_git_env(&mut command);
1219 let _ = command.output();
1220 }
1221
1222 for path in scan_non_reusable_orphan_paths(temp_root) {
1226 let _ = std::fs::remove_dir_all(&path);
1227 }
1228}
1229
1230fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
1233 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1234 return false;
1235 };
1236 let mut removed_any = false;
1237 for path in worktrees {
1238 if !is_fallow_audit_worktree_path(&path)
1239 || is_reusable_audit_worktree_path(&path)
1240 || audit_worktree_process_is_alive(&path)
1241 {
1242 continue;
1243 }
1244 remove_audit_worktree(repo_root, &path);
1245 let _ = std::fs::remove_dir_all(&path);
1246 removed_any = true;
1247 }
1248 removed_any
1249}
1250
1251fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
1254 let Ok(entries) = std::fs::read_dir(temp) else {
1255 return Vec::new();
1256 };
1257 let mut paths = Vec::new();
1258 for entry in entries.flatten() {
1259 let name = entry.file_name();
1260 let Some(name) = name.to_str() else {
1261 continue;
1262 };
1263 let Some(pid) = audit_worktree_pid(name) else {
1264 continue;
1265 };
1266 if process_is_alive(pid) || !entry.path().is_dir() {
1267 continue;
1268 }
1269 paths.push(temp.join(name));
1270 }
1271 paths
1272}
1273
1274pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1275 let mut command = Command::new("git");
1276 command
1277 .args(["worktree", "list", "--porcelain"])
1278 .current_dir(repo_root);
1279 clear_ambient_git_env(&mut command);
1280 let output = command.output().ok()?;
1281 if !output.status.success() {
1282 return None;
1283 }
1284 Some(parse_worktree_list(&String::from_utf8_lossy(
1285 &output.stdout,
1286 )))
1287}
1288
1289pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1290 output
1291 .lines()
1292 .filter_map(|line| line.strip_prefix("worktree "))
1293 .map(PathBuf::from)
1294 .filter(|path| is_fallow_audit_worktree_path(path))
1295 .collect()
1296}
1297
1298pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1299 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1300 return false;
1301 };
1302 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1303}
1304
1305pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1306 path.file_name()
1307 .and_then(|name| name.to_str())
1308 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1309}
1310
1311fn path_is_inside_temp_dir(path: &Path) -> bool {
1312 let temp = std::env::temp_dir();
1313 let simple_path = dunce::simplified(path);
1314 let simple_temp = dunce::simplified(&temp);
1315 if simple_path.starts_with(simple_temp) {
1316 return true;
1317 }
1318 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1319 return false;
1320 };
1321 let simple_canonical_temp = dunce::simplified(&canonical_temp);
1322 simple_path.starts_with(simple_canonical_temp)
1323 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1324 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1325 })
1326}
1327
1328fn audit_worktree_process_is_alive(path: &Path) -> bool {
1329 let Some(pid) = path
1330 .file_name()
1331 .and_then(|name| name.to_str())
1332 .and_then(audit_worktree_pid)
1333 else {
1334 return false;
1335 };
1336 process_is_alive(pid)
1337}
1338
1339pub fn audit_worktree_pid(name: &str) -> Option<u32> {
1340 name.strip_prefix("fallow-audit-base-")?
1341 .split('-')
1342 .next()?
1343 .parse()
1344 .ok()
1345}
1346
1347#[cfg(unix)]
1348pub fn process_is_alive(pid: u32) -> bool {
1349 Command::new("kill")
1350 .args(["-0", &pid.to_string()])
1351 .output()
1352 .is_ok_and(|output| output.status.success())
1353}
1354
1355#[cfg(windows)]
1356pub fn process_is_alive(pid: u32) -> bool {
1357 windows_process::is_alive(pid)
1358}
1359
1360#[cfg(not(any(unix, windows)))]
1361pub fn process_is_alive(_pid: u32) -> bool {
1362 true
1363}
1364
1365#[cfg(windows)]
1366#[allow(
1367 unsafe_code,
1368 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1369)]
1370mod windows_process {
1371 use windows_sys::Win32::Foundation::{
1372 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1373 WAIT_OBJECT_0,
1374 };
1375 use windows_sys::Win32::System::Threading::{
1376 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1377 };
1378
1379 struct ProcessHandle(HANDLE);
1383
1384 impl Drop for ProcessHandle {
1385 fn drop(&mut self) {
1386 unsafe {
1390 CloseHandle(self.0);
1391 }
1392 }
1393 }
1394
1395 pub fn is_alive(pid: u32) -> bool {
1403 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1407 if raw.is_null() {
1408 let err = unsafe { GetLastError() };
1411 #[expect(
1412 clippy::match_same_arms,
1413 reason = "named arm documents the cross-session case"
1414 )]
1415 return match err {
1416 ERROR_INVALID_PARAMETER => false,
1417 ERROR_ACCESS_DENIED => true,
1418 _ => true,
1419 };
1420 }
1421 let handle = ProcessHandle(raw);
1422 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1425 wait_result != WAIT_OBJECT_0
1426 }
1427}
1428
1429impl Drop for BaseWorktree {
1430 fn drop(&mut self) {
1431 if self.persistent {
1432 return;
1433 }
1434 let _ = std::fs::remove_dir_all(&self.path);
1438 }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444
1445 #[test]
1451 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
1452 const N: usize = 64;
1453 let barrier = std::sync::Barrier::new(N);
1454 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
1455 std::thread::scope(|s| {
1456 for _ in 0..N {
1457 let barrier = &barrier;
1458 let paths = &paths;
1459 s.spawn(move || {
1460 barrier.wait();
1461 let path = non_reusable_worktree_path().expect("path should build");
1462 paths.lock().unwrap().push(path);
1463 });
1464 }
1465 });
1466 let mut paths = paths.into_inner().unwrap();
1467 assert_eq!(paths.len(), N);
1468 paths.sort();
1469 paths.dedup();
1470 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
1471 }
1472
1473 #[test]
1475 fn non_reusable_worktree_path_pid_is_parseable() {
1476 let path = non_reusable_worktree_path().expect("path should build");
1477 let name = path.file_name().unwrap().to_str().unwrap();
1478 assert!(is_fallow_audit_worktree_path(&path));
1479 assert!(!is_reusable_audit_worktree_path(&path));
1480 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
1481 }
1482
1483 #[cfg(unix)]
1484 #[test]
1485 fn cache_sidecar_open_does_not_follow_symlinks() {
1486 let temp = tempfile::TempDir::new().expect("temp dir should be created");
1487 let victim = temp.path().join("victim");
1488 let sidecar = temp.path().join("cache.lock");
1489 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
1490 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
1491
1492 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
1493 assert_eq!(
1494 std::fs::read_to_string(victim).expect("victim should remain readable"),
1495 "unchanged\n",
1496 );
1497 }
1498}