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 record_last_used(worktree.path(), repo_root);
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 record_last_used(worktree.path(), repo_root);
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 stamp_last_used(reusable_path, None);
359}
360
361pub fn record_last_used(reusable_path: &Path, owner_root: &Path) {
368 stamp_last_used(reusable_path, Some(owner_root));
369}
370
371fn stamp_last_used(reusable_path: &Path, owner_root: Option<&Path>) {
372 let last_used = reusable_worktree_last_used_path(reusable_path);
373 let result = open_or_create_owned_sidecar(&last_used).and_then(|mut file| {
374 if let Some(owner_root) = owner_root {
375 file.set_len(0)?;
376 file.write_all(format!("{}\n", owner_root.display()).as_bytes())?;
377 }
378 file.set_modified(SystemTime::now())
379 });
380 if let Err(err) = result {
381 tracing::warn!(
382 path = %last_used.display(),
383 error = %err,
384 "failed to touch reusable audit worktree sidecar; staleness signal may not update",
385 );
386 }
387}
388
389fn read_last_used_owner(reusable_path: &Path) -> Option<PathBuf> {
394 const MAX_OWNER_SIDECAR_BYTES: u64 = 4096;
395
396 let sidecar = reusable_worktree_last_used_path(reusable_path);
397 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
398 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_OWNER_SIDECAR_BYTES {
399 return None;
400 }
401 let mut contents = String::new();
402 std::fs::File::open(sidecar)
403 .ok()?
404 .take(MAX_OWNER_SIDECAR_BYTES)
405 .read_to_string(&mut contents)
406 .ok()?;
407 let owner = contents.trim();
408 if owner.is_empty() {
409 return None;
410 }
411 Some(PathBuf::from(owner))
412}
413
414fn open_or_create_owned_sidecar(path: &Path) -> std::io::Result<std::fs::File> {
415 match std::fs::symlink_metadata(path) {
416 Ok(metadata) if sidecar_metadata_is_trusted(&metadata) => {
417 std::fs::OpenOptions::new().write(true).open(path)
418 }
419 Ok(_) => Err(std::io::Error::new(
420 std::io::ErrorKind::PermissionDenied,
421 "refusing to open an untrusted audit cache sidecar",
422 )),
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
424 let mut options = std::fs::OpenOptions::new();
425 options.create_new(true).write(true);
426 #[cfg(unix)]
427 {
428 use std::os::unix::fs::OpenOptionsExt as _;
429 options.mode(0o600);
430 }
431 options.open(path)
432 }
433 Err(error) => Err(error),
434 }
435}
436
437#[cfg(unix)]
438fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
439 use std::os::unix::fs::MetadataExt as _;
440
441 metadata_is_regular_file(metadata) && metadata.uid() == rustix::process::geteuid().as_raw()
442}
443
444#[cfg(not(unix))]
445fn sidecar_metadata_is_trusted(metadata: &std::fs::Metadata) -> bool {
446 metadata_is_regular_file(metadata)
447}
448
449#[expect(
450 clippy::filetype_is_file,
451 reason = "security-sensitive sidecars and gitfiles must be regular files, not arbitrary non-directories"
452)]
453fn metadata_is_regular_file(metadata: &std::fs::Metadata) -> bool {
454 metadata.file_type().is_file()
455}
456
457pub fn resolve_cache_max_age_with_options(
464 root: &Path,
465 config_path: Option<&PathBuf>,
466 allow_remote_extends: bool,
467) -> Option<Duration> {
468 if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
469 if let Ok(days) = raw.trim().parse::<u32>() {
470 return days_to_duration(days);
471 }
472 tracing::warn!(
473 value = %raw,
474 "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
475 );
476 }
477 if let Some(days) = load_audit_config(root, config_path, allow_remote_extends)
478 .and_then(|c| c.cache_max_age_days)
479 {
480 return days_to_duration(days);
481 }
482 days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
483}
484
485pub fn days_to_duration(days: u32) -> Option<Duration> {
486 if days == 0 {
487 return None;
488 }
489 Some(Duration::from_secs(u64::from(days) * 86_400))
490}
491
492fn load_audit_config(
496 root: &Path,
497 config_path: Option<&PathBuf>,
498 allow_remote_extends: bool,
499) -> Option<fallow_config::AuditConfig> {
500 let options = fallow_config::ConfigLoadOptions {
501 allow_remote_extends,
502 };
503 if let Some(path) = config_path {
504 return fallow_config::FallowConfig::load_with_options(path, options)
505 .ok()
506 .map(|config| config.audit);
507 }
508 fallow_config::FallowConfig::find_and_load_with_options(root, options)
509 .ok()
510 .flatten()
511 .map(|(config, _path)| config.audit)
512}
513
514pub fn sweep_old_reusable_caches(repo_root: &Path, max_age: Option<Duration>, quiet: bool) {
549 sweep_old_reusable_caches_in(repo_root, max_age, quiet, &std::env::temp_dir());
550}
551
552pub fn sweep_old_reusable_caches_in(
558 repo_root: &Path,
559 max_age: Option<Duration>,
560 quiet: bool,
561 scan_root: &Path,
562) {
563 if deregister_legacy_reusable_caches(repo_root) {
569 let mut command = Command::new("git");
570 command
571 .args(["worktree", "prune", "--expire=now"])
572 .current_dir(repo_root);
573 clear_ambient_git_env(&mut command);
574 let _ = command.output();
575 }
576
577 let mut paths = vec![reusable_audit_worktree_path(repo_root)];
581 paths.extend(scan_legacy_reusable_cache_paths(repo_root, scan_root));
582 paths.sort();
583 paths.dedup();
584 let now = SystemTime::now();
585 let mut removed: u32 = 0;
586 for path in &paths {
587 if reclaim_reusable_cache_entry(repo_root, path, max_age, now) {
588 removed += 1;
589 }
590 }
591
592 let scoped: FxHashSet<&PathBuf> = paths.iter().collect();
599 for path in scan_all_reusable_cache_paths(scan_root) {
600 if scoped.contains(&path) {
601 continue;
602 }
603 if reclaim_foreign_cache_entry(repo_root, &path, max_age, now) {
604 removed += 1;
605 }
606 }
607 if removed == 0 {
608 return;
609 }
610 tracing::info!(
611 count = removed,
612 "reclaimed stale audit base-snapshot caches",
613 );
614 if !quiet {
615 let s = plural(removed as usize);
616 let _ = writeln!(
617 std::io::stderr(),
618 "fallow: reclaimed {removed} stale base-snapshot cache{s}",
619 );
620 }
621}
622
623fn deregister_legacy_reusable_caches(repo_root: &Path) -> bool {
628 let Some(worktrees) = list_audit_worktrees(repo_root) else {
629 return false;
630 };
631 let mut deregistered = false;
632 for path in worktrees {
633 if !is_reusable_audit_worktree_path(&path) {
634 continue;
635 }
636 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
637 continue;
638 };
639 if !audit_worktree_is_registered(repo_root, &path) {
640 continue;
641 }
642 let is_current_path = paths_equal(&path, &reusable_audit_worktree_path(repo_root));
643 let head = is_current_path
644 .then(|| legacy_reusable_sha(&path))
645 .flatten();
646 if unregister_worktree_checked(repo_root, &path).is_err() {
647 continue;
648 }
649 if is_current_path {
650 if let Some(head) = head {
651 let _ = write_reusable_sha(&path, &head);
652 }
653 } else if let Err(error) = remove_reusable_cache_entry_locked(repo_root, &path) {
654 tracing::warn!(
655 path = %path.display(),
656 error = %error,
657 "failed to remove released SHA-keyed audit cache",
658 );
659 }
660 deregistered = true;
661 }
662 deregistered
663}
664
665fn legacy_reusable_sha(path: &Path) -> Option<String> {
669 if reusable_worktree_sha_path(path).exists()
670 || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
671 {
672 return None;
673 }
674 git_rev_parse(path, "HEAD")
675}
676
677fn scan_legacy_reusable_cache_paths(repo_root: &Path, scan_root: &Path) -> Vec<PathBuf> {
682 let Some(prefix) = legacy_reusable_cache_repo_prefix(repo_root) else {
683 return Vec::new();
684 };
685 scan_cache_paths_with_hex_suffix(&prefix, scan_root)
686}
687
688fn scan_root_owned_cache_paths(repo_root: &Path) -> Vec<PathBuf> {
689 let Some(prefix) = root_owned_cache_repo_prefix(repo_root) else {
690 return Vec::new();
691 };
692 scan_cache_paths_with_hex_suffix(&prefix, &std::env::temp_dir())
693}
694
695fn scan_all_reusable_cache_paths(scan_root: &Path) -> Vec<PathBuf> {
701 const GLOBAL_CACHE_PREFIX: &str = "fallow-audit-base-cache-";
702
703 let Ok(entries) = std::fs::read_dir(scan_root) else {
704 return Vec::new();
705 };
706 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
707 let mut paths = Vec::new();
708 for entry in entries.flatten() {
709 let name = entry.file_name();
710 let Some(name) = name.to_str() else {
711 continue;
712 };
713 let cache_name = strip_cache_sidecar_suffix(name);
714 let Some(hash_suffix) = cache_name.strip_prefix(GLOBAL_CACHE_PREFIX) else {
715 continue;
716 };
717 if !cache_hash_suffix_is_valid(hash_suffix) {
718 continue;
719 }
720 let path = scan_root.join(cache_name);
721 if seen.insert(path.clone()) {
722 paths.push(path);
723 }
724 }
725 paths
726}
727
728fn cache_hash_suffix_is_valid(suffix: &str) -> bool {
729 fn is_hex16(part: &str) -> bool {
730 part.len() == 16 && part.bytes().all(|byte| byte.is_ascii_hexdigit())
731 }
732 if let Some((repo, root)) = suffix.split_once("-root-") {
733 return is_hex16(repo) && is_hex16(root);
734 }
735 suffix
736 .split_once('-')
737 .is_some_and(|(repo, sha)| is_hex16(repo) && is_hex16(sha))
738}
739
740fn scan_cache_paths_with_hex_suffix(prefix: &str, scan_root: &Path) -> Vec<PathBuf> {
741 let Ok(entries) = std::fs::read_dir(scan_root) else {
742 return Vec::new();
743 };
744 let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
745 let mut paths = Vec::new();
746 for entry in entries.flatten() {
747 let name = entry.file_name();
748 let Some(name) = name.to_str() else {
749 continue;
750 };
751 let cache_name = strip_cache_sidecar_suffix(name);
752 let Some(hash_suffix) = cache_name.strip_prefix(prefix) else {
753 continue;
754 };
755 if hash_suffix.len() != 16 || !hash_suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
756 continue;
757 }
758 let path = scan_root.join(cache_name);
759 if seen.insert(path.clone()) {
760 paths.push(path);
761 }
762 }
763 paths
764}
765
766fn strip_cache_sidecar_suffix(name: &str) -> &str {
770 for suffix in [
771 REUSABLE_LAST_USED_SUFFIX,
772 REUSABLE_SHA_SUFFIX,
773 REUSABLE_LOCK_SUFFIX,
774 ] {
775 if let Some(stripped) = name.strip_suffix(suffix) {
776 return stripped;
777 }
778 }
779 name
780}
781
782fn reclaim_reusable_cache_entry(
785 repo_root: &Path,
786 path: &Path,
787 max_age: Option<Duration>,
788 now: SystemTime,
789) -> bool {
790 if !path.exists() {
798 return reclaim_orphan_cache_entry(repo_root, path);
799 }
800 let Some(max_age) = max_age else {
801 return false;
802 };
803 reclaim_aged_cache_entry(repo_root, path, max_age, now)
804}
805
806fn reclaim_orphan_cache_entry(repo_root: &Path, path: &Path) -> bool {
812 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
813 return false;
814 };
815 if path.exists() {
818 return false;
819 }
820 remove_reusable_cache_entry_locked(repo_root, path).unwrap_or(false)
821}
822
823fn reclaim_aged_cache_entry(
829 repo_root: &Path,
830 path: &Path,
831 max_age: Duration,
832 now: SystemTime,
833) -> bool {
834 let Some(mtime) = last_used_mtime(path) else {
835 record_last_used(path, repo_root);
836 return false;
837 };
838 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
839}
840
841fn reclaim_foreign_cache_entry(
853 repo_root: &Path,
854 path: &Path,
855 max_age: Option<Duration>,
856 now: SystemTime,
857) -> bool {
858 if !path.exists() {
859 return reclaim_orphan_cache_entry(repo_root, path);
860 }
861 if read_last_used_owner(path).is_some_and(|owner| owner.exists()) {
862 return false;
863 }
864 let Some(max_age) = max_age else {
865 return false;
866 };
867 let Some(mtime) = last_used_mtime(path) else {
868 touch_last_used(path);
869 return false;
870 };
871 remove_entry_past_max_age(repo_root, path, max_age, now, mtime)
872}
873
874fn last_used_mtime(path: &Path) -> Option<SystemTime> {
875 std::fs::metadata(reusable_worktree_last_used_path(path))
876 .ok()
877 .and_then(|metadata| metadata.modified().ok())
878}
879
880fn remove_entry_past_max_age(
881 repo_root: &Path,
882 path: &Path,
883 max_age: Duration,
884 now: SystemTime,
885 mtime: SystemTime,
886) -> bool {
887 let Ok(age) = now.duration_since(mtime) else {
888 return false;
889 };
890 if age < max_age {
891 return false;
892 }
893 let Some(_lock) = ReusableWorktreeLock::try_acquire(path) else {
894 return false;
895 };
896 match remove_reusable_cache_entry_locked(repo_root, path) {
897 Ok(removed) => removed,
898 Err(err) => {
899 tracing::warn!(
900 path = %path.display(),
901 error = %err,
902 "failed to remove stale reusable audit worktree entry; entry may leak",
903 );
904 false
905 }
906 }
907}
908
909pub fn canonical_root_hash(root: &Path) -> u64 {
910 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
911 xxh3_64(&path_identity_bytes(&canonical_root))
912}
913
914#[cfg(unix)]
915fn path_identity_bytes(path: &Path) -> Vec<u8> {
916 use std::os::unix::ffi::OsStrExt as _;
917
918 path.as_os_str().as_bytes().to_vec()
919}
920
921#[cfg(windows)]
922fn path_identity_bytes(path: &Path) -> Vec<u8> {
923 use std::os::windows::ffi::OsStrExt as _;
924
925 path.as_os_str()
926 .encode_wide()
927 .flat_map(u16::to_le_bytes)
928 .collect()
929}
930
931#[cfg(not(any(unix, windows)))]
932fn path_identity_bytes(path: &Path) -> Vec<u8> {
933 path.to_string_lossy().as_bytes().to_vec()
934}
935
936pub fn reusable_audit_worktree_path(requested_root: &Path) -> PathBuf {
937 let root_hash = canonical_root_hash(requested_root);
938 let repo_hash = git_toplevel(requested_root)
939 .as_deref()
940 .map_or(root_hash, canonical_root_hash);
941 std::env::temp_dir().join(format!(
942 "fallow-audit-base-cache-{repo_hash:016x}-root-{root_hash:016x}"
943 ))
944}
945
946fn root_owned_cache_repo_prefix(requested_root: &Path) -> Option<String> {
947 let git_root = git_toplevel(requested_root)?;
948 let repo_hash = canonical_root_hash(&git_root);
949 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-root-"))
950}
951
952fn legacy_reusable_cache_repo_prefix(requested_root: &Path) -> Option<String> {
953 let git_root = git_toplevel(requested_root)?;
954 let repo_hash = canonical_root_hash(&git_root);
955 Some(format!("fallow-audit-base-cache-{repo_hash:016x}-"))
956}
957
958#[cfg(test)]
959pub fn legacy_reusable_audit_worktree_path(
960 requested_root: &Path,
961 base_sha: &str,
962) -> Option<PathBuf> {
963 let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
964 Some(std::env::temp_dir().join(format!(
965 "{}{sha_prefix}",
966 legacy_reusable_cache_repo_prefix(requested_root)?
967 )))
968}
969
970fn reusable_audit_worktree_is_ready(path: &Path, base_sha: &str) -> bool {
981 if !reusable_cache_directory_is_trusted(path) {
982 return false;
983 }
984 let recorded = read_reusable_sha(path);
985 if recorded.as_deref() != Some(base_sha) {
986 return false;
987 }
988 repair_unregistered_git_stub(path)
989}
990
991fn read_reusable_sha(path: &Path) -> Option<String> {
992 const MAX_SHA_SIDECAR_BYTES: u64 = 129;
993
994 let sidecar = reusable_worktree_sha_path(path);
995 let metadata = std::fs::symlink_metadata(&sidecar).ok()?;
996 if !metadata_is_regular_file(&metadata) || metadata.len() > MAX_SHA_SIDECAR_BYTES {
997 return None;
998 }
999 let mut contents = String::new();
1000 std::fs::File::open(sidecar)
1001 .ok()?
1002 .take(MAX_SHA_SIDECAR_BYTES)
1003 .read_to_string(&mut contents)
1004 .ok()?;
1005 Some(contents.trim().to_owned())
1006}
1007
1008#[cfg(unix)]
1009fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1010 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1011
1012 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1013 return false;
1014 };
1015 metadata.file_type().is_dir()
1016 && metadata.uid() == rustix::process::geteuid().as_raw()
1017 && metadata.permissions().mode().trailing_zeros() >= 6
1018}
1019
1020#[cfg(not(unix))]
1021fn reusable_cache_directory_is_trusted(path: &Path) -> bool {
1022 std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
1023}
1024
1025fn try_migrate_registered_current_cache(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1031 if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1032 return false;
1033 }
1034 let head_matches = git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha);
1035 if !head_matches || !fallow_engine::repo_refs::detached_base_worktree_is_raw_materialized(path)
1036 {
1037 return false;
1038 }
1039 if unregister_worktree_checked(repo_root, path).is_err() {
1040 return false;
1041 }
1042 write_reusable_sha(path, base_sha).is_ok()
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1052pub struct AuditCacheRemovalReport {
1053 pub found: usize,
1054 pub removed: usize,
1055 pub skipped: usize,
1056 pub dry_run: bool,
1057}
1058
1059pub fn remove_reusable_audit_caches(
1060 requested_root: &Path,
1061 dry_run: bool,
1062) -> std::io::Result<AuditCacheRemovalReport> {
1063 let mut paths = vec![reusable_audit_worktree_path(requested_root)];
1064 paths.extend(scan_legacy_reusable_cache_paths(
1065 requested_root,
1066 &std::env::temp_dir(),
1067 ));
1068 if git_toplevel(requested_root).is_some_and(|root| paths_equal(&root, requested_root)) {
1069 paths.extend(scan_root_owned_cache_paths(requested_root));
1070 }
1071 paths.sort();
1072 paths.dedup();
1073
1074 let mut report = AuditCacheRemovalReport {
1075 found: 0,
1076 removed: 0,
1077 skipped: 0,
1078 dry_run,
1079 };
1080 for path in paths {
1081 if !reusable_cache_entry_exists(&path) {
1082 continue;
1083 }
1084 report.found += 1;
1085 if dry_run {
1086 continue;
1091 }
1092 let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
1093 report.skipped += 1;
1094 continue;
1095 };
1096 if remove_reusable_cache_entry_locked(requested_root, &path)? {
1097 report.removed += 1;
1098 }
1099 }
1100 Ok(report)
1101}
1102
1103fn reusable_cache_entry_exists(path: &Path) -> bool {
1104 path_entry_exists(path)
1105 || path_entry_exists(&reusable_worktree_sha_path(path))
1106 || path_entry_exists(&reusable_worktree_last_used_path(path))
1107}
1108
1109fn path_entry_exists(path: &Path) -> bool {
1110 std::fs::symlink_metadata(path).is_ok()
1111}
1112
1113fn remove_reusable_cache_entry_locked(repo_root: &Path, path: &Path) -> std::io::Result<bool> {
1116 let existed = reusable_cache_entry_exists(path);
1117 ensure_cache_entry_is_owned(path)?;
1118 if trusted_worktree_admin_dir(repo_root, path).is_some() {
1119 unregister_worktree_checked(repo_root, path)?;
1120 }
1121 remove_dir_if_exists(path)?;
1122 remove_file_if_exists(&reusable_worktree_sha_path(path))?;
1123 remove_file_if_exists(&reusable_worktree_last_used_path(path))?;
1124 Ok(existed)
1125}
1126
1127#[cfg(unix)]
1128fn ensure_cache_entry_is_owned(path: &Path) -> std::io::Result<()> {
1129 use std::os::unix::fs::MetadataExt as _;
1130
1131 let effective_uid = rustix::process::geteuid().as_raw();
1132 for entry in [
1133 path.to_path_buf(),
1134 reusable_worktree_sha_path(path),
1135 reusable_worktree_last_used_path(path),
1136 ] {
1137 let metadata = match std::fs::symlink_metadata(&entry) {
1138 Ok(metadata) => metadata,
1139 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1140 Err(error) => return Err(error),
1141 };
1142 if metadata.uid() != effective_uid {
1143 return Err(std::io::Error::new(
1144 std::io::ErrorKind::PermissionDenied,
1145 format!(
1146 "refusing to remove unowned audit cache entry `{}`",
1147 entry.display()
1148 ),
1149 ));
1150 }
1151 }
1152 Ok(())
1153}
1154
1155#[cfg(not(unix))]
1156#[expect(
1157 clippy::unnecessary_wraps,
1158 reason = "shared cross-platform signature; the Unix ownership check is fallible, non-Unix has no POSIX owner to verify"
1159)]
1160fn ensure_cache_entry_is_owned(_path: &Path) -> std::io::Result<()> {
1161 Ok(())
1162}
1163
1164fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> {
1165 match std::fs::remove_dir_all(path) {
1166 Ok(()) => Ok(()),
1167 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1168 Err(err) => Err(err),
1169 }
1170}
1171
1172fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
1173 match std::fs::remove_file(path) {
1174 Ok(()) => Ok(()),
1175 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1176 Err(err) => Err(err),
1177 }
1178}
1179
1180pub fn unregister_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1190 unregister_worktree_checked(repo_root, path)
1191}
1192
1193fn unregister_worktree_checked(repo_root: &Path, path: &Path) -> std::io::Result<()> {
1194 if !path.exists() {
1195 return Ok(());
1196 }
1197 let gitfile = path.join(".git");
1198 let metadata = std::fs::symlink_metadata(&gitfile)?;
1199 if !metadata_is_regular_file(&metadata) {
1200 return Err(std::io::Error::new(
1201 std::io::ErrorKind::InvalidData,
1202 "refusing to deregister through a non-file audit worktree .git entry",
1203 ));
1204 }
1205 let contents = std::fs::read_to_string(&gitfile)?;
1206 if contents == UNREGISTERED_GITDIR_STUB {
1207 return Ok(());
1208 }
1209 let Some(admin_dir) = trusted_worktree_admin_dir(repo_root, path) else {
1210 return Err(std::io::Error::new(
1211 std::io::ErrorKind::InvalidData,
1212 "refusing to deregister an unverified audit worktree admin entry",
1213 ));
1214 };
1215 remove_dir_if_exists(&admin_dir)?;
1216 write_git_stub_safely(&gitfile)
1217}
1218
1219fn repair_unregistered_git_stub(path: &Path) -> bool {
1224 let gitfile = path.join(".git");
1225 let Ok(metadata) = std::fs::symlink_metadata(&gitfile) else {
1226 return write_git_stub_safely(&gitfile).is_ok();
1227 };
1228 if !metadata_is_regular_file(&metadata) {
1229 return false;
1230 }
1231 std::fs::read_to_string(&gitfile).is_ok_and(|contents| contents == UNREGISTERED_GITDIR_STUB)
1232}
1233
1234fn write_git_stub_safely(gitfile: &Path) -> std::io::Result<()> {
1235 let mut options = std::fs::OpenOptions::new();
1236 options.write(true).truncate(true);
1237 match std::fs::symlink_metadata(gitfile) {
1238 Ok(metadata) if metadata_is_regular_file(&metadata) => {}
1239 Ok(_) => {
1240 return Err(std::io::Error::new(
1241 std::io::ErrorKind::InvalidData,
1242 "refusing to replace non-file audit worktree .git entry",
1243 ));
1244 }
1245 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1246 options.create_new(true);
1247 }
1248 Err(error) => return Err(error),
1249 }
1250 let mut file = options.open(gitfile)?;
1251 file.write_all(UNREGISTERED_GITDIR_STUB.as_bytes())?;
1252 file.sync_all()
1253}
1254
1255fn trusted_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
1256 let gitfile = path.join(".git");
1257 let metadata = std::fs::symlink_metadata(&gitfile).ok()?;
1258 if !metadata_is_regular_file(&metadata) {
1259 return None;
1260 }
1261 let contents = std::fs::read_to_string(&gitfile).ok()?;
1262 let admin_dir = parse_worktree_gitdir(&contents)?;
1263 if !is_fallow_admin_dir(&admin_dir) {
1264 return None;
1265 }
1266 let common_dir = fallow_engine::changed_files::resolve_git_common_dir(repo_root).ok()?;
1267 let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
1268 let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
1269 if admin_parent != worktrees_dir {
1270 return None;
1271 }
1272 let backlink = std::fs::read_to_string(admin_dir.join("gitdir")).ok()?;
1273 let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
1274 let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
1275 (actual_gitfile == expected_gitfile).then_some(admin_dir)
1276}
1277
1278fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
1280 contents
1281 .lines()
1282 .find_map(|line| line.trim().strip_prefix("gitdir:"))
1283 .map(|rest| PathBuf::from(rest.trim()))
1284}
1285
1286fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
1289 admin_dir
1290 .file_name()
1291 .and_then(|name| name.to_str())
1292 .is_some_and(|name| name.starts_with("fallow-audit-base-"))
1293}
1294
1295pub fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
1296 let mut command = Command::new("git");
1297 command.args(["rev-parse", rev]).current_dir(root);
1298 clear_ambient_git_env(&mut command);
1299 let output = command.output().ok()?;
1300 if !output.status.success() {
1301 return None;
1302 }
1303 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1304}
1305
1306pub fn git_toplevel(root: &Path) -> Option<PathBuf> {
1307 let mut command = Command::new("git");
1308 command
1309 .args(["rev-parse", "--show-toplevel"])
1310 .current_dir(root);
1311 clear_ambient_git_env(&mut command);
1312 let output = command.output().ok()?;
1313 if !output.status.success() {
1314 return None;
1315 }
1316 let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1317 Some(dunce::canonicalize(&path).unwrap_or(path))
1318}
1319
1320fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1321 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1322 return false;
1323 };
1324 worktrees.iter().any(|worktree| paths_equal(worktree, path))
1325}
1326
1327pub fn paths_equal(left: &Path, right: &Path) -> bool {
1328 if left == right {
1329 return true;
1330 }
1331 match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1332 (Ok(left), Ok(right)) => left == right,
1333 _ => false,
1334 }
1335}
1336
1337pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1338 let mut command = Command::new("git");
1339 command
1340 .args([
1341 "worktree",
1342 "remove",
1343 "--force",
1344 path.to_string_lossy().as_ref(),
1345 ])
1346 .current_dir(repo_root);
1347 clear_ambient_git_env(&mut command);
1348 match crate::signal::scoped_child::output(&mut command) {
1349 Ok(output) => {
1350 if !output.status.success() && path.exists() {
1351 let stderr = String::from_utf8_lossy(&output.stderr);
1352 tracing::warn!(
1353 path = %path.display(),
1354 stderr = %stderr.trim(),
1355 "git worktree remove failed; the directory remains and may leak",
1356 );
1357 }
1358 }
1359 Err(err) => {
1360 tracing::warn!(
1361 path = %path.display(),
1362 error = %err,
1363 "git worktree remove subprocess failed to spawn",
1364 );
1365 }
1366 }
1367}
1368
1369pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1370 sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir());
1371}
1372
1373pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path) {
1379 if deregister_legacy_orphan_worktrees(repo_root) {
1384 let mut command = Command::new("git");
1385 command
1386 .args(["worktree", "prune", "--expire=now"])
1387 .current_dir(repo_root);
1388 clear_ambient_git_env(&mut command);
1389 let _ = command.output();
1390 }
1391
1392 for path in scan_non_reusable_orphan_paths(temp_root) {
1396 let _ = std::fs::remove_dir_all(&path);
1397 }
1398}
1399
1400fn deregister_legacy_orphan_worktrees(repo_root: &Path) -> bool {
1403 let Some(worktrees) = list_audit_worktrees(repo_root) else {
1404 return false;
1405 };
1406 let mut removed_any = false;
1407 for path in worktrees {
1408 if !is_fallow_audit_worktree_path(&path)
1409 || is_reusable_audit_worktree_path(&path)
1410 || audit_worktree_process_is_alive(&path)
1411 {
1412 continue;
1413 }
1414 remove_audit_worktree(repo_root, &path);
1415 let _ = std::fs::remove_dir_all(&path);
1416 removed_any = true;
1417 }
1418 removed_any
1419}
1420
1421fn scan_non_reusable_orphan_paths(temp: &Path) -> Vec<PathBuf> {
1424 let Ok(entries) = std::fs::read_dir(temp) else {
1425 return Vec::new();
1426 };
1427 let mut paths = Vec::new();
1428 for entry in entries.flatten() {
1429 let name = entry.file_name();
1430 let Some(name) = name.to_str() else {
1431 continue;
1432 };
1433 let Some(pid) = audit_worktree_pid(name) else {
1434 continue;
1435 };
1436 if process_is_alive(pid) || !entry.path().is_dir() {
1437 continue;
1438 }
1439 paths.push(temp.join(name));
1440 }
1441 paths
1442}
1443
1444pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1445 let mut command = Command::new("git");
1446 command
1447 .args(["worktree", "list", "--porcelain"])
1448 .current_dir(repo_root);
1449 clear_ambient_git_env(&mut command);
1450 let output = command.output().ok()?;
1451 if !output.status.success() {
1452 return None;
1453 }
1454 Some(parse_worktree_list(&String::from_utf8_lossy(
1455 &output.stdout,
1456 )))
1457}
1458
1459pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1460 output
1461 .lines()
1462 .filter_map(|line| line.strip_prefix("worktree "))
1463 .map(PathBuf::from)
1464 .filter(|path| is_fallow_audit_worktree_path(path))
1465 .collect()
1466}
1467
1468pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1469 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1470 return false;
1471 };
1472 name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1473}
1474
1475pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1476 path.file_name()
1477 .and_then(|name| name.to_str())
1478 .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1479}
1480
1481fn path_is_inside_temp_dir(path: &Path) -> bool {
1482 let temp = std::env::temp_dir();
1483 let simple_path = dunce::simplified(path);
1484 let simple_temp = dunce::simplified(&temp);
1485 if simple_path.starts_with(simple_temp) {
1486 return true;
1487 }
1488 let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1489 return false;
1490 };
1491 let simple_canonical_temp = dunce::simplified(&canonical_temp);
1492 simple_path.starts_with(simple_canonical_temp)
1493 || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1494 dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1495 })
1496}
1497
1498fn audit_worktree_process_is_alive(path: &Path) -> bool {
1499 let Some(pid) = path
1500 .file_name()
1501 .and_then(|name| name.to_str())
1502 .and_then(audit_worktree_pid)
1503 else {
1504 return false;
1505 };
1506 process_is_alive(pid)
1507}
1508
1509pub fn audit_worktree_pid(name: &str) -> Option<u32> {
1510 name.strip_prefix("fallow-audit-base-")?
1511 .split('-')
1512 .next()?
1513 .parse()
1514 .ok()
1515}
1516
1517#[cfg(unix)]
1518pub fn process_is_alive(pid: u32) -> bool {
1519 Command::new("kill")
1520 .args(["-0", &pid.to_string()])
1521 .output()
1522 .is_ok_and(|output| output.status.success())
1523}
1524
1525#[cfg(windows)]
1526pub fn process_is_alive(pid: u32) -> bool {
1527 windows_process::is_alive(pid)
1528}
1529
1530#[cfg(not(any(unix, windows)))]
1531pub fn process_is_alive(_pid: u32) -> bool {
1532 true
1533}
1534
1535#[cfg(windows)]
1536#[allow(
1537 unsafe_code,
1538 reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1539)]
1540mod windows_process {
1541 use windows_sys::Win32::Foundation::{
1542 CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1543 WAIT_OBJECT_0,
1544 };
1545 use windows_sys::Win32::System::Threading::{
1546 OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1547 };
1548
1549 struct ProcessHandle(HANDLE);
1553
1554 impl Drop for ProcessHandle {
1555 fn drop(&mut self) {
1556 unsafe {
1560 CloseHandle(self.0);
1561 }
1562 }
1563 }
1564
1565 pub fn is_alive(pid: u32) -> bool {
1573 let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1577 if raw.is_null() {
1578 let err = unsafe { GetLastError() };
1581 #[expect(
1582 clippy::match_same_arms,
1583 reason = "named arm documents the cross-session case"
1584 )]
1585 return match err {
1586 ERROR_INVALID_PARAMETER => false,
1587 ERROR_ACCESS_DENIED => true,
1588 _ => true,
1589 };
1590 }
1591 let handle = ProcessHandle(raw);
1592 let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1595 wait_result != WAIT_OBJECT_0
1596 }
1597}
1598
1599impl Drop for BaseWorktree {
1600 fn drop(&mut self) {
1601 if self.persistent {
1602 return;
1603 }
1604 let _ = std::fs::remove_dir_all(&self.path);
1608 }
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 #[test]
1621 fn non_reusable_worktree_paths_are_unique_under_concurrency() {
1622 const N: usize = 64;
1623 let barrier = std::sync::Barrier::new(N);
1624 let paths = std::sync::Mutex::new(Vec::with_capacity(N));
1625 std::thread::scope(|s| {
1626 for _ in 0..N {
1627 let barrier = &barrier;
1628 let paths = &paths;
1629 s.spawn(move || {
1630 barrier.wait();
1631 let path = non_reusable_worktree_path().expect("path should build");
1632 paths.lock().unwrap().push(path);
1633 });
1634 }
1635 });
1636 let mut paths = paths.into_inner().unwrap();
1637 assert_eq!(paths.len(), N);
1638 paths.sort();
1639 paths.dedup();
1640 assert_eq!(paths.len(), N, "non-reusable worktree paths collided");
1641 }
1642
1643 #[test]
1645 fn non_reusable_worktree_path_pid_is_parseable() {
1646 let path = non_reusable_worktree_path().expect("path should build");
1647 let name = path.file_name().unwrap().to_str().unwrap();
1648 assert!(is_fallow_audit_worktree_path(&path));
1649 assert!(!is_reusable_audit_worktree_path(&path));
1650 assert_eq!(audit_worktree_pid(name), Some(std::process::id()));
1651 }
1652
1653 #[cfg(unix)]
1654 #[test]
1655 fn cache_sidecar_open_does_not_follow_symlinks() {
1656 let temp = tempfile::TempDir::new().expect("temp dir should be created");
1657 let victim = temp.path().join("victim");
1658 let sidecar = temp.path().join("cache.lock");
1659 std::fs::write(&victim, "unchanged\n").expect("victim should be written");
1660 std::os::unix::fs::symlink(&victim, &sidecar).expect("sidecar symlink should be created");
1661
1662 assert!(open_or_create_owned_sidecar(&sidecar).is_err());
1663 assert_eq!(
1664 std::fs::read_to_string(victim).expect("victim should remain readable"),
1665 "unchanged\n",
1666 );
1667 }
1668}