Skip to main content

aft/
root_cache.rs

1//! These files coordinate safe access to a project-root cache: a writer lease
2//! ensures only one process updates the cache at a time, and read-marker files
3//! let cleanup see which readers are still using the cache.
4//!
5//! Writer leases are stored at `<storage>/callgraph/<artifact_cache_key>/writer.lease`
6//! and `<storage>/inspect/<project_scope_key>/writer.lease`. They use the
7//! `fs_lock` JSON format with a `writer_epoch` nonce so a writer can detect if
8//! another process has taken over before publishing changes or starting SQLite
9//! write transactions.
10//!
11//! Read markers track active SQLite readers so cache cleanup can tell when it is
12//! safe to remove old data. They are stored under
13//! `<cache-domain>/readers/<generation-label>/<pid>.<hostname>.<created_at_ms>.<seq>.json`;
14//! the JSON records the process identity and creation time, mtime is used as a
15//! heartbeat for cleanup across hosts, and the PID is used for cleanup on the
16//! same host. Marker files are created `0600` so they do not expose checkout
17//! activity or let another local user delete a protected marker.
18
19use std::collections::{HashMap, HashSet};
20use std::ffi::OsStr;
21use std::fs::{self, File, OpenOptions};
22use std::io::{self, Write};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
25use std::sync::{Arc, Mutex, OnceLock, TryLockError, Weak};
26use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
27
28use serde::{Deserialize, Serialize};
29
30use crate::fs_lock;
31
32static MARKER_SEQ: AtomicU64 = AtomicU64::new(0);
33static LIVE_SCOPE_KEYS: OnceLock<Mutex<HashMap<(PathBuf, String), usize>>> = OnceLock::new();
34
35/// Read-marker heartbeats refresh no more often than the filesystem lock
36/// heartbeat. Active readers piggyback this on normal read paths instead of
37/// spawning a thread per connection.
38pub const READ_MARKER_TOUCH_INTERVAL_MS: u64 = fs_lock::HEARTBEAT_INTERVAL_MS;
39/// Cross-host markers cannot use local PID liveness, so they expire after the
40/// same conservative 5x stale-heartbeat window used by filesystem locks.
41pub const READ_MARKER_CROSS_HOST_STALE_MS: u64 = fs_lock::STALE_HEARTBEAT_MS * 5;
42// Process start timestamps are not always millisecond-precise across OS APIs.
43// A one-second grace keeps a marker created immediately after process launch
44// attached to that process while still identifying clear PID reuse.
45const PROCESS_START_TIME_GRACE_MS: u64 = 1_000;
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48pub enum RootCacheDomain {
49    Callgraph,
50    Inspect,
51    /// Search-index publication for an unbound standing root. This remains a
52    /// root-keyed WriterLease domain, separate from the session-owned cache.
53    Index,
54}
55
56impl RootCacheDomain {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            RootCacheDomain::Callgraph => "callgraph",
60            RootCacheDomain::Inspect => "inspect",
61            RootCacheDomain::Index => "index",
62        }
63    }
64}
65
66/// Serializes artifact supersession with the final disk publication step.
67/// Advancing an epoch either happens before a stale worker checks and prevents
68/// its publish, or after that worker has fully published the still-current
69/// generation. This closes the check-then-publish race of a bare atomic epoch.
70#[derive(Clone, Default)]
71pub struct ArtifactPublishEpoch {
72    current: Arc<parking_lot::Mutex<u64>>,
73}
74
75impl ArtifactPublishEpoch {
76    pub fn next(&self) -> u64 {
77        let mut current = self.current.lock();
78        *current = current.wrapping_add(1);
79        *current
80    }
81
82    pub fn current(&self) -> u64 {
83        *self.current.lock()
84    }
85
86    pub fn run_if_current<R>(&self, expected: u64, publish: impl FnOnce() -> R) -> Option<R> {
87        let current = self.current.lock();
88        if *current != expected {
89            return None;
90        }
91        Some(publish())
92    }
93}
94
95pub struct WriterLease {
96    domain: RootCacheDomain,
97    key: String,
98    path: PathBuf,
99    epoch: String,
100    guard: Mutex<fs_lock::LockGuard>,
101}
102
103#[derive(Clone, Debug, Eq, Hash, PartialEq)]
104struct ProcessLeaseKey {
105    domain: RootCacheDomain,
106    cache_dir: PathBuf,
107}
108
109#[derive(Clone, Debug, Eq, Hash, PartialEq)]
110struct WriterLeaseAcquisitionKey {
111    domain: RootCacheDomain,
112    key: String,
113    project_root: PathBuf,
114}
115
116static PROCESS_LEASES: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>>> =
117    OnceLock::new();
118// Same-root callers share this short-lived gate so only one thread performs the
119// filesystem lease attempt, while different roots do not wait on the registry
120// mutex during stat/probe/create/heartbeat work.
121static PROCESS_LEASE_ACQUISITIONS: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>>> =
122    OnceLock::new();
123static WRITER_LEASE_ACQUISITION_COUNTS: OnceLock<Mutex<HashMap<WriterLeaseAcquisitionKey, usize>>> =
124    OnceLock::new();
125static WRITER_LEASE_ACQUISITION_COUNTER_ENABLED: AtomicBool = AtomicBool::new(false);
126static CONFIGURED_ARTIFACT_ACCESS: OnceLock<Mutex<HashMap<PathBuf, ArtifactAccess>>> =
127    OnceLock::new();
128static WARNED_BORROW_ONLY_WRITES: OnceLock<Mutex<HashSet<(PathBuf, PathBuf)>>> = OnceLock::new();
129
130/// Root-scoped capability that distinguishes shared repository artifacts from
131/// mutable state private to one checkout.
132#[derive(Clone, Debug)]
133pub struct ArtifactAccess {
134    project_root: PathBuf,
135    shared_key: String,
136    private_key: String,
137    borrow_only_shared: bool,
138    registered: bool,
139}
140
141impl ArtifactAccess {
142    fn configured(project_root: &Path, shared_key: &str, borrow_only_shared: bool) -> Self {
143        let project_root = canonical_root(project_root);
144        Self {
145            private_key: crate::path_identity::project_scope_key(&project_root),
146            project_root,
147            shared_key: shared_key.to_string(),
148            borrow_only_shared,
149            registered: true,
150        }
151    }
152
153    /// Resolve the capability registered during configure. Unregistered roots
154    /// receive a borrow-only capability without probing Git on the store path.
155    pub fn for_root(project_root: &Path) -> Self {
156        let project_root = canonical_root(project_root);
157        if let Some(access) = configured_artifact_access()
158            .lock()
159            .ok()
160            .and_then(|access| access.get(&project_root).cloned())
161        {
162            return access;
163        }
164        // Unregistered root: fail closed without spawning git probes. Configure
165        // registers every bound root before any store acquisition, so landing
166        // here means a direct artifact-API caller on an unconfigured root —
167        // treating it as borrow-only keeps shared artifacts safe and keeps this
168        // path subprocess-free (a git probe here has unbounded latency and can
169        // run on latency-critical threads).
170        crate::slog_warn!(
171            "artifact access requested for unconfigured root {}; defaulting to borrow-only",
172            project_root.display()
173        );
174        let shared_key = crate::path_identity::project_scope_key(&project_root);
175        Self {
176            private_key: shared_key.clone(),
177            project_root,
178            shared_key,
179            borrow_only_shared: true,
180            registered: false,
181        }
182    }
183
184    /// Return whether this root may write the keyed artifact, logging the first
185    /// denial for each concrete path so read-only degradation stays observable.
186    pub fn allows_write(&self, artifact_key: &str, write_path: &Path) -> bool {
187        let writes_keyed_dir = write_path
188            .ancestors()
189            .any(|ancestor| ancestor.file_name() == Some(OsStr::new(artifact_key)));
190        if !self.borrow_only_shared
191            || artifact_key != self.shared_key
192            || artifact_key == self.private_key
193            || !writes_keyed_dir
194        {
195            return true;
196        }
197        self.warn_write_denied_once(write_path);
198        false
199    }
200
201    fn allows_writer_lease(
202        &self,
203        domain: RootCacheDomain,
204        artifact_key: &str,
205        write_path: &Path,
206    ) -> bool {
207        self.allows_writer_lease_inner(domain, artifact_key, write_path)
208    }
209
210    fn allows_writer_lease_inner(
211        &self,
212        domain: RootCacheDomain,
213        artifact_key: &str,
214        write_path: &Path,
215    ) -> bool {
216        // Classify the layout from a comparison-local canonical copy rather
217        // than trusting key equality. A key derived while the root's final
218        // component was absent can encode a symlink alias (for example,
219        // /var versus /private/var) that disappears after the root exists.
220        let comparison_cache_dir = write_path.parent().map(canonical_process_lease_dir);
221        let writes_keyed_dir = comparison_cache_dir.as_deref().and_then(Path::file_name)
222            == Some(OsStr::new(artifact_key));
223        let writes_root_keyed_dir = comparison_cache_dir
224            .as_deref()
225            .and_then(Path::parent)
226            .and_then(Path::file_name)
227            == Some(OsStr::new(domain.as_str()));
228        if !self.borrow_only_shared {
229            return true;
230        }
231        if domain != RootCacheDomain::Callgraph
232            && writes_keyed_dir
233            && artifact_key == self.private_key
234        {
235            return true;
236        }
237        if !self.registered && !writes_root_keyed_dir {
238            // Caller-owned scratch paths are outside the shared root-keyed
239            // cache layout, so this capability does not govern them.
240            return true;
241        }
242        self.warn_write_denied_once(write_path);
243        false
244    }
245
246    fn warn_write_denied_once(&self, write_path: &Path) {
247        let warning_key = (self.project_root.clone(), write_path.to_path_buf());
248        let should_warn = WARNED_BORROW_ONLY_WRITES
249            .get_or_init(|| Mutex::new(HashSet::new()))
250            .lock()
251            .map(|mut warned| {
252                if warned.len() >= 4_096 {
253                    warned.clear();
254                }
255                warned.insert(warning_key)
256            })
257            .unwrap_or(false);
258        if should_warn {
259            crate::slog_warn!(
260                "borrow-only root denied shared artifact write at {} (configured shared key {})",
261                write_path.display(),
262                self.shared_key
263            );
264        }
265    }
266}
267
268fn configured_artifact_access() -> &'static Mutex<HashMap<PathBuf, ArtifactAccess>> {
269    CONFIGURED_ARTIFACT_ACCESS.get_or_init(|| Mutex::new(HashMap::new()))
270}
271
272/// Track scope keys belonging to roots currently bound in this process. The
273/// inspect sweep snapshots this registry at publication time so a live actor's
274/// cache cannot be mistaken for a reclaimed worktree cache.
275fn live_scope_keys() -> &'static Mutex<HashMap<(PathBuf, String), usize>> {
276    LIVE_SCOPE_KEYS.get_or_init(|| Mutex::new(HashMap::new()))
277}
278
279pub(crate) fn register_live_scope(storage_root: &Path, project_root: &Path) {
280    let storage_root = canonical_root(storage_root);
281    let scope_key = crate::path_identity::project_scope_key(project_root);
282    let mut scopes = match live_scope_keys().lock() {
283        Ok(scopes) => scopes,
284        Err(poisoned) => poisoned.into_inner(),
285    };
286    *scopes.entry((storage_root, scope_key)).or_default() += 1;
287}
288
289pub(crate) fn unregister_live_scope(storage_root: &Path, project_root: &Path) {
290    let storage_root = canonical_root(storage_root);
291    let scope_key = crate::path_identity::project_scope_key(project_root);
292    let mut scopes = match live_scope_keys().lock() {
293        Ok(scopes) => scopes,
294        Err(poisoned) => poisoned.into_inner(),
295    };
296    let key = (storage_root, scope_key);
297    if let Some(count) = scopes.get_mut(&key) {
298        *count = count.saturating_sub(1);
299        if *count == 0 {
300            scopes.remove(&key);
301        }
302    }
303}
304
305pub(crate) fn live_scope_keys_for_storage(storage_root: &Path) -> HashSet<String> {
306    let storage_root = canonical_root(storage_root);
307    let scopes = match live_scope_keys().lock() {
308        Ok(scopes) => scopes,
309        Err(poisoned) => poisoned.into_inner(),
310    };
311    scopes
312        .iter()
313        .filter(|((root, _), count)| root == &storage_root && **count > 0)
314        .map(|((_, key), _)| key.clone())
315        .collect()
316}
317
318/// Register the worktree topology already detected by configure so artifact
319/// APIs can enforce it without repeating a Git subprocess on every write path.
320pub fn configure_artifact_access(project_root: &Path, shared_key: &str, borrow_only_shared: bool) {
321    let access = ArtifactAccess::configured(project_root, shared_key, borrow_only_shared);
322    if let Ok(mut configured) = configured_artifact_access().lock() {
323        // Bounded, but never a wholesale clear: dropping still-live roots'
324        // capabilities would silently flip them onto the fail-closed
325        // (borrow-only) fallback. Evict an arbitrary other entry instead —
326        // any evicted-but-live root re-registers on its next configure.
327        if configured.len() >= 4_096 && !configured.contains_key(&access.project_root) {
328            if let Some(evict) = configured.keys().next().cloned() {
329                configured.remove(&evict);
330            }
331        }
332        configured.insert(access.project_root.clone(), access);
333    }
334}
335
336fn canonical_root(project_root: &Path) -> PathBuf {
337    // Nearest-existing-ancestor canonicalization, not a raw-spelling fallback:
338    // registration can precede the directory's creation (and lookups can follow
339    // it), and on macOS the raw tempdir spelling (/var/...) differs from the
340    // canonical one (/private/var/...). Keying the access registry by whichever
341    // spelling happened to win the existence race made a registered root miss
342    // its own entry and fail closed.
343    canonical_process_lease_dir(project_root)
344}
345
346fn process_leases() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>> {
347    PROCESS_LEASES.get_or_init(|| Mutex::new(HashMap::new()))
348}
349
350fn process_lease_acquisitions() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>> {
351    PROCESS_LEASE_ACQUISITIONS.get_or_init(|| Mutex::new(HashMap::new()))
352}
353
354fn writer_lease_acquisition_counts() -> &'static Mutex<HashMap<WriterLeaseAcquisitionKey, usize>> {
355    WRITER_LEASE_ACQUISITION_COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
356}
357
358fn record_writer_lease_acquisition(domain: RootCacheDomain, key: &str, project_root: &Path) {
359    if !WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.load(Ordering::Relaxed) {
360        return;
361    }
362    let project_root =
363        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
364    let acquisition_key = WriterLeaseAcquisitionKey {
365        domain,
366        key: key.to_string(),
367        project_root,
368    };
369    if let Ok(mut counts) = writer_lease_acquisition_counts().lock() {
370        *counts.entry(acquisition_key).or_default() += 1;
371    }
372}
373
374/// Turn the acquisition counter on for the test process without clearing it.
375///
376/// There is deliberately no reset: the counter map is keyed by
377/// (domain, key, project_root) and every consuming test uses its own fresh
378/// temp-dir root, so each test's key starts at zero by construction. A global
379/// clear here was the only cross-test coupling - under parallel execution it
380/// wiped entries between another test's setup and its assertion, producing
381/// isolated-green/parallel-red flakes.
382#[doc(hidden)]
383pub fn enable_writer_lease_acquisition_counts_for_test() {
384    WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.store(true, Ordering::Relaxed);
385}
386
387#[doc(hidden)]
388pub fn writer_lease_acquisition_count_for_test(
389    domain: RootCacheDomain,
390    key: &str,
391    project_root: &Path,
392) -> usize {
393    let project_root =
394        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
395    writer_lease_acquisition_counts()
396        .lock()
397        .ok()
398        .and_then(|counts| {
399            counts
400                .get(&WriterLeaseAcquisitionKey {
401                    domain,
402                    key: key.to_string(),
403                    project_root,
404                })
405                .copied()
406        })
407        .unwrap_or(0)
408}
409
410fn shared_process_lease(
411    registry_key: &ProcessLeaseKey,
412) -> Result<Option<Arc<WriterLease>>, fs_lock::AcquireError> {
413    let mut leases = process_leases().lock().map_err(|_| {
414        fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
415    })?;
416    if let Some(existing) = leases.get(registry_key).and_then(Weak::upgrade) {
417        if existing.verify()? {
418            return Ok(Some(existing));
419        }
420        leases.remove(registry_key);
421    }
422    Ok(None)
423}
424
425fn process_lease_acquisition_lock(
426    registry_key: &ProcessLeaseKey,
427) -> Result<Arc<Mutex<()>>, fs_lock::AcquireError> {
428    let mut acquisitions = process_lease_acquisitions().lock().map_err(|_| {
429        fs_lock::AcquireError::Io(io::Error::other(
430            "process lease acquisition registry poisoned",
431        ))
432    })?;
433    if let Some(existing) = acquisitions.get(registry_key).and_then(Weak::upgrade) {
434        return Ok(existing);
435    }
436    if acquisitions.len() > 1024 {
437        acquisitions.retain(|_, lock| lock.strong_count() > 0);
438    }
439    let lock = Arc::new(Mutex::new(()));
440    acquisitions.insert(registry_key.clone(), Arc::downgrade(&lock));
441    Ok(lock)
442}
443
444#[cfg(test)]
445type AcquireSharedHook = Arc<dyn Fn(RootCacheDomain, &Path, &str) + Send + Sync + 'static>;
446
447#[cfg(test)]
448static ACQUIRE_SHARED_HOOK: OnceLock<Mutex<Option<AcquireSharedHook>>> = OnceLock::new();
449
450#[cfg(test)]
451fn set_acquire_shared_hook_for_test(hook: Option<AcquireSharedHook>) {
452    *ACQUIRE_SHARED_HOOK
453        .get_or_init(|| Mutex::new(None))
454        .lock()
455        .expect("acquire shared hook mutex") = hook;
456}
457
458#[cfg(test)]
459fn run_acquire_shared_hook_for_test(domain: RootCacheDomain, cache_dir: &Path, key: &str) {
460    let hook = ACQUIRE_SHARED_HOOK
461        .get_or_init(|| Mutex::new(None))
462        .lock()
463        .expect("acquire shared hook mutex")
464        .clone();
465    if let Some(hook) = hook {
466        hook(domain, cache_dir, key);
467    }
468}
469
470#[cfg(not(test))]
471fn run_acquire_shared_hook_for_test(_domain: RootCacheDomain, _cache_dir: &Path, _key: &str) {}
472
473impl std::fmt::Debug for WriterLease {
474    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        formatter
476            .debug_struct("WriterLease")
477            .field("domain", &self.domain)
478            .field("key", &self.key)
479            .field("path", &self.path)
480            .field("epoch", &self.epoch)
481            .finish_non_exhaustive()
482    }
483}
484
485impl WriterLease {
486    /// Acquire a shared writer lease.
487    ///
488    /// Same-process callers wait without a timeout because the current lease
489    /// attempt may need filesystem work that takes several seconds. The
490    /// filesystem lock acquisition itself is nonblocking.
491    pub fn acquire_shared(
492        domain: RootCacheDomain,
493        cache_dir: &Path,
494        key: &str,
495        project_root: &Path,
496    ) -> Result<Option<Arc<Self>>, fs_lock::AcquireError> {
497        Self::acquire_shared_inner(domain, cache_dir, key, project_root, None)
498    }
499
500    /// Acquire a process-shared writer lease without waiting past `timeout`.
501    ///
502    /// The deadline covers both the same-process single-flight gate and the
503    /// filesystem lease. A bounded caller must not become unbounded merely
504    /// because another thread is currently performing the filesystem attempt.
505    pub fn acquire_shared_with_timeout(
506        domain: RootCacheDomain,
507        cache_dir: &Path,
508        key: &str,
509        project_root: &Path,
510        timeout: Duration,
511    ) -> Result<Option<Arc<Self>>, fs_lock::AcquireError> {
512        Self::acquire_shared_inner(domain, cache_dir, key, project_root, Some(timeout))
513    }
514
515    fn acquire_shared_inner(
516        domain: RootCacheDomain,
517        cache_dir: &Path,
518        key: &str,
519        project_root: &Path,
520        timeout: Option<Duration>,
521    ) -> Result<Option<Arc<Self>>, fs_lock::AcquireError> {
522        let deadline = timeout.map(|timeout| Instant::now() + timeout);
523        let access = ArtifactAccess::for_root(project_root);
524        if !access.allows_writer_lease(domain, key, &writer_lease_path(cache_dir)) {
525            return Ok(None);
526        }
527        let registry_key = ProcessLeaseKey {
528            domain,
529            cache_dir: canonical_process_lease_dir(cache_dir),
530        };
531        if let Some(existing) = shared_process_lease(&registry_key)? {
532            record_writer_lease_acquisition(domain, key, project_root);
533            return Ok(Some(existing));
534        }
535
536        let acquisition_lock = process_lease_acquisition_lock(&registry_key)?;
537        let _acquisition_guard = if let Some(deadline) = deadline {
538            loop {
539                match acquisition_lock.try_lock() {
540                    Ok(guard) => break guard,
541                    Err(TryLockError::Poisoned(_)) => {
542                        return Err(fs_lock::AcquireError::Io(io::Error::other(
543                            "process lease acquisition poisoned",
544                        )));
545                    }
546                    Err(TryLockError::WouldBlock) => {
547                        let now = Instant::now();
548                        if now >= deadline {
549                            return Err(fs_lock::AcquireError::Timeout);
550                        }
551                        std::thread::sleep(
552                            Duration::from_millis(10).min(deadline.saturating_duration_since(now)),
553                        );
554                    }
555                }
556            }
557        } else {
558            acquisition_lock.lock().map_err(|_| {
559                fs_lock::AcquireError::Io(io::Error::other("process lease acquisition poisoned"))
560            })?
561        };
562
563        if let Some(existing) = shared_process_lease(&registry_key)? {
564            record_writer_lease_acquisition(domain, key, project_root);
565            return Ok(Some(existing));
566        }
567
568        run_acquire_shared_hook_for_test(domain, cache_dir, key);
569
570        let filesystem_timeout = deadline
571            .map(|deadline| deadline.saturating_duration_since(Instant::now()))
572            .unwrap_or(Duration::ZERO);
573        let lease = Arc::new(Self::acquire(domain, cache_dir, key, filesystem_timeout)?);
574        process_leases()
575            .lock()
576            .map_err(|_| {
577                fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
578            })?
579            .insert(registry_key, Arc::downgrade(&lease));
580        record_writer_lease_acquisition(domain, key, project_root);
581        Ok(Some(lease))
582    }
583
584    fn acquire(
585        domain: RootCacheDomain,
586        cache_dir: &Path,
587        key: &str,
588        timeout: Duration,
589    ) -> Result<Self, fs_lock::AcquireError> {
590        if !storage_allows_root_keyed(cache_dir)? {
591            return Err(fs_lock::AcquireError::Io(io::Error::new(
592                io::ErrorKind::PermissionDenied,
593                format!(
594                    "refusing root-keyed {} writer lease on a network filesystem at {}",
595                    domain.as_str(),
596                    cache_dir.display()
597                ),
598            )));
599        }
600        if let Some(storage_root) = cache_dir.parent().and_then(Path::parent) {
601            crate::legacy_partitions::guard_new_layout_write_path(
602                storage_root,
603                cache_dir,
604                "root-keyed writer lease",
605            )?;
606        }
607        fs::create_dir_all(cache_dir)?;
608        let guard = fs_lock::try_acquire(&writer_lease_path(cache_dir), timeout)?;
609        if !guard.verify_writer_epoch()? {
610            return Err(fs_lock::AcquireError::Io(io::Error::other(
611                "writer lease epoch changed immediately after acquisition",
612            )));
613        }
614        let path = guard.path().to_path_buf();
615        let epoch = guard.writer_epoch().to_string();
616        Ok(Self {
617            domain,
618            key: key.to_string(),
619            path,
620            epoch,
621            guard: Mutex::new(guard),
622        })
623    }
624
625    pub fn verify(&self) -> io::Result<bool> {
626        self.guard
627            .lock()
628            .map_err(|_| io::Error::other("writer lease mutex poisoned"))?
629            .verify_writer_epoch()
630    }
631
632    pub fn epoch(&self) -> &str {
633        &self.epoch
634    }
635
636    pub fn domain(&self) -> RootCacheDomain {
637        self.domain
638    }
639
640    pub fn key(&self) -> &str {
641        &self.key
642    }
643
644    pub fn path(&self) -> &Path {
645        &self.path
646    }
647}
648
649pub fn writer_lease_path(cache_dir: &Path) -> PathBuf {
650    cache_dir.join("writer.lease")
651}
652
653#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
654pub struct ReadMarkerMetadata {
655    pub pid: u32,
656    pub hostname: String,
657    pub created_at_ms: u64,
658}
659
660#[derive(Debug)]
661pub struct ReadMarker {
662    path: PathBuf,
663    metadata: ReadMarkerMetadata,
664    last_touched_at_ms: AtomicU64,
665}
666
667impl ReadMarker {
668    pub fn create(cache_dir: &Path, generation_label: &str) -> io::Result<Self> {
669        let metadata = ReadMarkerMetadata {
670            pid: std::process::id(),
671            hostname: current_hostname(),
672            created_at_ms: now_ms(),
673        };
674        let dir = read_marker_dir(cache_dir, generation_label);
675        fs::create_dir_all(&dir)?;
676        let seq = MARKER_SEQ.fetch_add(1, Ordering::Relaxed);
677        let path = dir.join(format!(
678            "{}.{}.{}.{}.json",
679            metadata.pid,
680            sanitize_marker_component(&metadata.hostname),
681            metadata.created_at_ms,
682            seq
683        ));
684        write_marker_file(&path, &metadata)?;
685        let last_touched_at_ms = AtomicU64::new(metadata.created_at_ms);
686        Ok(Self {
687            path,
688            metadata,
689            last_touched_at_ms,
690        })
691    }
692
693    pub fn touch(&self) -> io::Result<()> {
694        write_marker_file(&self.path, &self.metadata)?;
695        self.last_touched_at_ms.store(now_ms(), Ordering::Relaxed);
696        Ok(())
697    }
698
699    pub fn touch_if_due(&self) -> io::Result<()> {
700        let now = now_ms();
701        let last = self.last_touched_at_ms.load(Ordering::Relaxed);
702        if now.saturating_sub(last) < READ_MARKER_TOUCH_INTERVAL_MS {
703            return Ok(());
704        }
705        self.touch()
706    }
707
708    pub fn path(&self) -> &Path {
709        &self.path
710    }
711
712    pub fn metadata(&self) -> &ReadMarkerMetadata {
713        &self.metadata
714    }
715}
716
717impl Drop for ReadMarker {
718    fn drop(&mut self) {
719        let _ = fs::remove_file(&self.path);
720        fs_lock::sync_parent(&self.path);
721    }
722}
723
724pub fn read_marker_dir(cache_dir: &Path, generation_label: &str) -> PathBuf {
725    cache_dir.join("readers").join(generation_label)
726}
727
728/// Sweep every generation marker below a cache directory, reusing the same
729/// PID-authoritative and cross-host-TTL liveness rules as generation GC.
730pub(crate) fn sweep_all_read_markers(cache_dir: &Path) -> ReadMarkerSweep {
731    let readers = cache_dir.join("readers");
732    let entries = match fs::read_dir(&readers) {
733        Ok(entries) => entries,
734        Err(error) if error.kind() == io::ErrorKind::NotFound => return ReadMarkerSweep::default(),
735        Err(_) => {
736            return ReadMarkerSweep {
737                protected: true,
738                removed_stale: 0,
739            }
740        }
741    };
742
743    let mut sweep = ReadMarkerSweep::default();
744    for entry in entries {
745        let Ok(entry) = entry else {
746            sweep.protected = true;
747            continue;
748        };
749        let path = entry.path();
750        let file_type = match entry.file_type() {
751            Ok(file_type) => file_type,
752            Err(_) => {
753                sweep.protected = true;
754                continue;
755            }
756        };
757        if !file_type.is_dir() {
758            continue;
759        }
760        let Some(label) = path.file_name().and_then(|name| name.to_str()) else {
761            sweep.protected = true;
762            continue;
763        };
764        let generation = sweep_read_markers(cache_dir, label);
765        sweep.protected |= generation.protected;
766        sweep.removed_stale += generation.removed_stale;
767    }
768    sweep
769}
770
771#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
772pub struct ReadMarkerSweep {
773    pub protected: bool,
774    pub removed_stale: usize,
775}
776
777pub fn protected_read_marker_exists(cache_dir: &Path, generation_label: &str) -> bool {
778    read_marker_protection(cache_dir, generation_label, false).protected
779}
780
781pub fn sweep_read_markers(cache_dir: &Path, generation_label: &str) -> ReadMarkerSweep {
782    read_marker_protection(cache_dir, generation_label, true)
783}
784
785fn read_marker_protection(
786    cache_dir: &Path,
787    generation_label: &str,
788    remove_stale: bool,
789) -> ReadMarkerSweep {
790    let dir = read_marker_dir(cache_dir, generation_label);
791    let entries = match fs::read_dir(&dir) {
792        Ok(entries) => entries,
793        Err(error) if error.kind() == io::ErrorKind::NotFound => return ReadMarkerSweep::default(),
794        Err(_) => {
795            return ReadMarkerSweep {
796                protected: true,
797                removed_stale: 0,
798            };
799        }
800    };
801
802    let hostname = current_hostname();
803    let now = now_ms();
804    let mut sweep = ReadMarkerSweep::default();
805    for entry in entries.flatten() {
806        let path = entry.path();
807        match marker_file_is_protected(&path, now, &hostname) {
808            MarkerProtection::Protected => sweep.protected = true,
809            MarkerProtection::Stale | MarkerProtection::Malformed => {
810                if remove_stale && fs::remove_file(&path).is_ok() {
811                    fs_lock::sync_parent(&path);
812                    sweep.removed_stale += 1;
813                }
814            }
815        }
816    }
817    sweep
818}
819
820#[derive(Clone, Copy, Debug, PartialEq, Eq)]
821enum MarkerProtection {
822    Protected,
823    Stale,
824    Malformed,
825}
826
827fn marker_file_is_protected(path: &Path, now: u64, current_host: &str) -> MarkerProtection {
828    let bytes = match fs::read(path) {
829        Ok(bytes) => bytes,
830        Err(error) if error.kind() == io::ErrorKind::NotFound => return MarkerProtection::Stale,
831        Err(_) => return MarkerProtection::Protected,
832    };
833    let metadata: ReadMarkerMetadata = match serde_json::from_slice(&bytes) {
834        Ok(metadata) => metadata,
835        Err(_) => return MarkerProtection::Malformed,
836    };
837    if metadata.hostname != current_host {
838        let Ok(file_metadata) = fs::metadata(path) else {
839            return MarkerProtection::Protected;
840        };
841        let mtime_ms = file_metadata
842            .modified()
843            .ok()
844            .map(system_time_ms)
845            .unwrap_or(now);
846        let age_ms = now.saturating_sub(mtime_ms);
847        return if age_ms <= READ_MARKER_CROSS_HOST_STALE_MS {
848            MarkerProtection::Protected
849        } else {
850            MarkerProtection::Stale
851        };
852    }
853
854    if !fs_lock::process_alive(metadata.pid) {
855        return MarkerProtection::Stale;
856    }
857    if marker_matches_live_process_instance(&metadata) {
858        MarkerProtection::Protected
859    } else {
860        MarkerProtection::Stale
861    }
862}
863
864fn marker_matches_live_process_instance(metadata: &ReadMarkerMetadata) -> bool {
865    // Same-host PID liveness is authoritative for the process instance. When the
866    // OS can tell us the live PID started after this marker was created, the PID
867    // has been reused and the marker belongs to a dead prior process; otherwise
868    // a live PID protects the marker without consulting marker mtime.
869    process_start_time_ms(metadata.pid)
870        .map(|started_at_ms| {
871            started_at_ms
872                <= metadata
873                    .created_at_ms
874                    .saturating_add(PROCESS_START_TIME_GRACE_MS)
875        })
876        .unwrap_or(true)
877}
878
879fn write_marker_file(path: &Path, metadata: &ReadMarkerMetadata) -> io::Result<()> {
880    let tmp = path.with_file_name(format!(
881        ".{}.tmp.{}.{}",
882        path.file_name()
883            .and_then(|name| name.to_str())
884            .unwrap_or("reader"),
885        std::process::id(),
886        now_nanos()
887    ));
888    let result = (|| {
889        let mut file = open_private_file(&tmp)?;
890        serde_json::to_writer(&mut file, metadata).map_err(io::Error::other)?;
891        file.write_all(b"\n")?;
892        file.sync_all()?;
893        drop(file);
894        fs_lock::rename_over(&tmp, path)?;
895        fs_lock::sync_parent(path);
896        Ok(())
897    })();
898    if result.is_err() {
899        let _ = fs::remove_file(&tmp);
900    }
901    result
902}
903
904#[cfg(unix)]
905fn open_private_file(path: &Path) -> io::Result<File> {
906    use std::os::unix::fs::OpenOptionsExt;
907
908    OpenOptions::new()
909        .write(true)
910        .create_new(true)
911        .mode(0o600)
912        .open(path)
913}
914
915#[cfg(not(unix))]
916fn open_private_file(path: &Path) -> io::Result<File> {
917    OpenOptions::new().write(true).create_new(true).open(path)
918}
919
920fn sanitize_marker_component(value: &str) -> String {
921    value
922        .chars()
923        .map(|ch| {
924            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
925                ch
926            } else {
927                '_'
928            }
929        })
930        .collect()
931}
932
933#[cfg(test)]
934static FORCE_NETWORK_FS_FOR_TEST: AtomicBool = AtomicBool::new(false);
935
936#[cfg(test)]
937pub fn set_force_network_fs_for_test(enabled: bool) {
938    FORCE_NETWORK_FS_FOR_TEST.store(enabled, Ordering::SeqCst);
939}
940
941pub fn storage_allows_root_keyed(path: &Path) -> io::Result<bool> {
942    #[cfg(test)]
943    if FORCE_NETWORK_FS_FOR_TEST.load(Ordering::SeqCst) {
944        return Ok(false);
945    }
946
947    let probe = existing_ancestor(path);
948    filesystem_is_local(&probe)
949}
950
951fn canonical_process_lease_dir(path: &Path) -> PathBuf {
952    if let Ok(canonical) = std::fs::canonicalize(path) {
953        return canonical;
954    }
955
956    let normalized = lexical_normalize(path);
957    let mut missing_components = Vec::new();
958    let mut current = normalized.as_path();
959    while !current.exists() {
960        let Some(name) = current.file_name() else {
961            return normalized;
962        };
963        missing_components.push(name.to_os_string());
964        let Some(parent) = current.parent() else {
965            return normalized;
966        };
967        current = parent;
968    }
969
970    let mut canonical = std::fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf());
971    for component in missing_components.iter().rev() {
972        canonical.push(component);
973    }
974    canonical
975}
976
977fn lexical_normalize(path: &Path) -> PathBuf {
978    let mut normalized = PathBuf::new();
979    for component in path.components() {
980        match component {
981            std::path::Component::CurDir => {}
982            std::path::Component::ParentDir => {
983                normalized.pop();
984            }
985            other => normalized.push(other.as_os_str()),
986        }
987    }
988    normalized
989}
990
991fn existing_ancestor(path: &Path) -> PathBuf {
992    let mut current = path;
993    loop {
994        if current.exists() {
995            return current.to_path_buf();
996        }
997        let Some(parent) = current.parent() else {
998            return PathBuf::from(".");
999        };
1000        current = parent;
1001    }
1002}
1003
1004#[cfg(target_os = "macos")]
1005fn filesystem_is_local(path: &Path) -> io::Result<bool> {
1006    use std::ffi::CString;
1007    use std::os::unix::ffi::OsStrExt;
1008
1009    let c_path = CString::new(path.as_os_str().as_bytes())
1010        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
1011    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
1012    if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
1013        return Err(io::Error::last_os_error());
1014    }
1015    let nul = stat
1016        .f_fstypename
1017        .iter()
1018        .position(|byte| *byte == 0)
1019        .unwrap_or(stat.f_fstypename.len());
1020    let fs_type = String::from_utf8_lossy(
1021        &stat.f_fstypename[..nul]
1022            .iter()
1023            .map(|byte| *byte as u8)
1024            .collect::<Vec<_>>(),
1025    )
1026    .to_ascii_lowercase();
1027    Ok(!matches!(
1028        fs_type.as_str(),
1029        "nfs" | "smbfs" | "afpfs" | "webdav" | "fusefs"
1030    ))
1031}
1032
1033#[cfg(all(unix, not(target_os = "macos")))]
1034fn filesystem_is_local(path: &Path) -> io::Result<bool> {
1035    use std::ffi::CString;
1036    use std::os::unix::ffi::OsStrExt;
1037
1038    let c_path = CString::new(path.as_os_str().as_bytes())
1039        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
1040    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
1041    if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
1042        return Err(io::Error::last_os_error());
1043    }
1044    let fs_type = stat.f_type as i64;
1045    const NFS_SUPER_MAGIC: i64 = 0x6969;
1046    const SMB_SUPER_MAGIC: i64 = 0x517B;
1047    const CIFS_MAGIC_NUMBER: i64 = 0xFF534D42;
1048    Ok(!matches!(
1049        fs_type,
1050        NFS_SUPER_MAGIC | SMB_SUPER_MAGIC | CIFS_MAGIC_NUMBER
1051    ))
1052}
1053
1054#[cfg(not(unix))]
1055fn filesystem_is_local(_path: &Path) -> io::Result<bool> {
1056    Ok(true)
1057}
1058
1059fn now_ms() -> u64 {
1060    system_time_ms(SystemTime::now())
1061}
1062
1063fn system_time_ms(time: SystemTime) -> u64 {
1064    time.duration_since(UNIX_EPOCH)
1065        .unwrap_or(Duration::ZERO)
1066        .as_millis() as u64
1067}
1068
1069fn now_nanos() -> u128 {
1070    SystemTime::now()
1071        .duration_since(UNIX_EPOCH)
1072        .unwrap_or(Duration::ZERO)
1073        .as_nanos()
1074}
1075
1076#[cfg(test)]
1077static PROCESS_START_TIME_OVERRIDES: OnceLock<Mutex<HashMap<u32, Option<u64>>>> = OnceLock::new();
1078
1079#[cfg(test)]
1080fn set_process_start_time_for_test(pid: u32, started_at_ms: Option<u64>) {
1081    PROCESS_START_TIME_OVERRIDES
1082        .get_or_init(|| Mutex::new(HashMap::new()))
1083        .lock()
1084        .expect("process start override mutex")
1085        .insert(pid, started_at_ms);
1086}
1087
1088#[cfg(test)]
1089fn clear_process_start_time_for_test(pid: u32) {
1090    if let Some(overrides) = PROCESS_START_TIME_OVERRIDES.get() {
1091        overrides
1092            .lock()
1093            .expect("process start override mutex")
1094            .remove(&pid);
1095    }
1096}
1097
1098#[cfg(test)]
1099fn process_start_time_override(pid: u32) -> Option<Option<u64>> {
1100    PROCESS_START_TIME_OVERRIDES
1101        .get()
1102        .and_then(|overrides| overrides.lock().ok()?.get(&pid).copied())
1103}
1104
1105#[cfg(target_os = "linux")]
1106pub(crate) fn process_start_time_ms(pid: u32) -> Option<u64> {
1107    #[cfg(test)]
1108    if let Some(override_value) = process_start_time_override(pid) {
1109        return override_value;
1110    }
1111
1112    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
1113    let after_comm = stat.rsplit_once(") ")?.1;
1114    let fields = after_comm.split_whitespace().collect::<Vec<_>>();
1115    let start_ticks = fields.get(19)?.parse::<u64>().ok()?;
1116    let boot_time_secs = fs::read_to_string("/proc/stat")
1117        .ok()?
1118        .lines()
1119        .find_map(|line| line.strip_prefix("btime ")?.parse::<u64>().ok())?;
1120    let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
1121    if ticks_per_second <= 0 {
1122        return None;
1123    }
1124    let ticks_per_second = ticks_per_second as u64;
1125    Some(
1126        boot_time_secs
1127            .saturating_mul(1_000)
1128            .saturating_add(start_ticks.saturating_mul(1_000) / ticks_per_second),
1129    )
1130}
1131
1132#[cfg(target_os = "macos")]
1133pub(crate) fn process_start_time_ms(pid: u32) -> Option<u64> {
1134    #[cfg(test)]
1135    if let Some(override_value) = process_start_time_override(pid) {
1136        return override_value;
1137    }
1138
1139    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
1140    let info_size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
1141    let bytes = unsafe {
1142        libc::proc_pidinfo(
1143            pid as libc::c_int,
1144            libc::PROC_PIDTBSDINFO,
1145            0,
1146            (&mut info as *mut libc::proc_bsdinfo).cast(),
1147            info_size,
1148        )
1149    };
1150    if bytes != info_size {
1151        return None;
1152    }
1153    Some(
1154        info.pbi_start_tvsec
1155            .saturating_mul(1_000)
1156            .saturating_add(info.pbi_start_tvusec / 1_000),
1157    )
1158}
1159
1160#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1161pub(crate) fn process_start_time_ms(pid: u32) -> Option<u64> {
1162    #[cfg(test)]
1163    if let Some(override_value) = process_start_time_override(pid) {
1164        return override_value;
1165    }
1166    let _ = pid;
1167    None
1168}
1169
1170#[cfg(unix)]
1171fn current_hostname() -> String {
1172    let mut buffer = [0u8; 256];
1173    let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
1174    if result == 0 {
1175        let len = buffer
1176            .iter()
1177            .position(|byte| *byte == 0)
1178            .unwrap_or(buffer.len());
1179        if len > 0 {
1180            return String::from_utf8_lossy(&buffer[..len]).into_owned();
1181        }
1182    }
1183    "unknown-host".to_string()
1184}
1185
1186#[cfg(windows)]
1187fn current_hostname() -> String {
1188    std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown-host".to_string())
1189}
1190
1191#[cfg(all(not(unix), not(windows)))]
1192fn current_hostname() -> String {
1193    "unknown-host".to_string()
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199
1200    #[test]
1201    fn read_marker_file_is_private_and_touchable() {
1202        let dir = tempfile::tempdir().unwrap();
1203        let marker = ReadMarker::create(dir.path(), "current").unwrap();
1204        assert!(marker.path().is_file());
1205        marker.touch().unwrap();
1206        let bytes = fs::read(marker.path()).unwrap();
1207        let parsed: ReadMarkerMetadata = serde_json::from_slice(&bytes).unwrap();
1208        assert_eq!(parsed.pid, std::process::id());
1209        #[cfg(unix)]
1210        {
1211            use std::os::unix::fs::PermissionsExt;
1212            assert_eq!(
1213                fs::metadata(marker.path()).unwrap().permissions().mode() & 0o777,
1214                0o600
1215            );
1216        }
1217    }
1218
1219    #[test]
1220    fn same_host_live_marker_ignores_stale_mtime() {
1221        let dir = tempfile::tempdir().unwrap();
1222        let marker = ReadMarker::create(dir.path(), "current").unwrap();
1223        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
1224
1225        assert!(protected_read_marker_exists(dir.path(), "current"));
1226    }
1227
1228    #[test]
1229    fn sweep_removes_dead_same_host_marker() {
1230        let dir = tempfile::tempdir().unwrap();
1231        let marker_path = read_marker_dir(dir.path(), "old").join("dead.json");
1232        let metadata = ReadMarkerMetadata {
1233            pid: 0,
1234            hostname: current_hostname(),
1235            created_at_ms: now_ms(),
1236        };
1237        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1238        write_marker_file(&marker_path, &metadata).unwrap();
1239
1240        let sweep = sweep_read_markers(dir.path(), "old");
1241
1242        assert!(!sweep.protected);
1243        assert_eq!(sweep.removed_stale, 1);
1244        assert!(!marker_path.exists());
1245    }
1246
1247    #[test]
1248    fn sweep_removes_reused_pid_marker_when_created_at_predates_process_start() {
1249        let dir = tempfile::tempdir().unwrap();
1250        let marker_path = read_marker_dir(dir.path(), "old").join("reused.json");
1251        let pid = std::process::id();
1252        let metadata = ReadMarkerMetadata {
1253            pid,
1254            hostname: current_hostname(),
1255            created_at_ms: 1_000,
1256        };
1257        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1258        write_marker_file(&marker_path, &metadata).unwrap();
1259        set_process_start_time_for_test(pid, Some(10_000));
1260
1261        let sweep = sweep_read_markers(dir.path(), "old");
1262        clear_process_start_time_for_test(pid);
1263
1264        assert!(!sweep.protected);
1265        assert_eq!(sweep.removed_stale, 1);
1266        assert!(!marker_path.exists());
1267    }
1268
1269    #[test]
1270    fn sweep_removes_expired_cross_host_marker() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let marker_path = read_marker_dir(dir.path(), "old").join("cross-host.json");
1273        let metadata = ReadMarkerMetadata {
1274            pid: 123,
1275            hostname: format!("other-{}", current_hostname()),
1276            created_at_ms: now_ms(),
1277        };
1278        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1279        write_marker_file(&marker_path, &metadata).unwrap();
1280        let stale_time = SystemTime::now()
1281            .checked_sub(Duration::from_millis(
1282                READ_MARKER_CROSS_HOST_STALE_MS.saturating_add(1_000),
1283            ))
1284            .unwrap_or(UNIX_EPOCH);
1285        filetime::set_file_mtime(
1286            &marker_path,
1287            filetime::FileTime::from_system_time(stale_time),
1288        )
1289        .unwrap();
1290
1291        let sweep = sweep_read_markers(dir.path(), "old");
1292
1293        assert!(!sweep.protected);
1294        assert_eq!(sweep.removed_stale, 1);
1295        assert!(!marker_path.exists());
1296    }
1297
1298    #[cfg(unix)]
1299    #[test]
1300    fn process_lease_dir_canonicalizes_existing_ancestor_before_cache_dir_exists() {
1301        let dir = tempfile::tempdir().unwrap();
1302        let real = dir.path().join("real");
1303        let link = dir.path().join("link");
1304        fs::create_dir_all(&real).unwrap();
1305        std::os::unix::fs::symlink(&real, &link).unwrap();
1306
1307        let missing_cache_dir = link.join("inspect").join("project");
1308        let before_create = canonical_process_lease_dir(&missing_cache_dir);
1309        fs::create_dir_all(&missing_cache_dir).unwrap();
1310        let after_create = canonical_process_lease_dir(&missing_cache_dir);
1311
1312        assert_eq!(before_create, after_create);
1313        assert!(before_create.starts_with(std::fs::canonicalize(&real).unwrap()));
1314    }
1315
1316    #[test]
1317    fn borrow_only_root_never_receives_existing_shared_writer_capability() {
1318        let storage = tempfile::tempdir().unwrap();
1319        let parent_root = tempfile::tempdir().unwrap();
1320        let worktree_root = tempfile::tempdir().unwrap();
1321        let shared_key = "shared-artifact-key";
1322        let cache_dir = storage.path().join("callgraph").join(shared_key);
1323        configure_artifact_access(parent_root.path(), shared_key, false);
1324        configure_artifact_access(worktree_root.path(), shared_key, true);
1325
1326        let parent_lease = WriterLease::acquire_shared(
1327            RootCacheDomain::Callgraph,
1328            &cache_dir,
1329            shared_key,
1330            parent_root.path(),
1331        )
1332        .unwrap()
1333        .expect("parent writer lease");
1334        enable_writer_lease_acquisition_counts_for_test();
1335
1336        let worktree_lease = WriterLease::acquire_shared(
1337            RootCacheDomain::Callgraph,
1338            &cache_dir,
1339            shared_key,
1340            worktree_root.path(),
1341        )
1342        .unwrap();
1343        let unkeyed_worktree_lease = WriterLease::acquire_shared(
1344            RootCacheDomain::Callgraph,
1345            &storage.path().join("scratch"),
1346            shared_key,
1347            worktree_root.path(),
1348        )
1349        .unwrap();
1350
1351        assert!(worktree_lease.is_none());
1352        assert!(unkeyed_worktree_lease.is_none());
1353        assert!(parent_lease.verify().unwrap());
1354        assert_eq!(
1355            writer_lease_acquisition_count_for_test(
1356                RootCacheDomain::Callgraph,
1357                shared_key,
1358                worktree_root.path(),
1359            ),
1360            0
1361        );
1362    }
1363
1364    #[test]
1365    fn unregistered_root_cannot_acquire_keyed_shared_writer_lease() {
1366        let storage = tempfile::tempdir().unwrap();
1367        let root = tempfile::tempdir().unwrap();
1368        let shared_key = "unregistered-shared-key";
1369        let cache_dir = storage.path().join("callgraph").join(shared_key);
1370        enable_writer_lease_acquisition_counts_for_test();
1371
1372        let lease = WriterLease::acquire_shared(
1373            RootCacheDomain::Callgraph,
1374            &cache_dir,
1375            shared_key,
1376            root.path(),
1377        )
1378        .unwrap();
1379
1380        assert!(lease.is_none());
1381        assert_eq!(
1382            writer_lease_acquisition_count_for_test(
1383                RootCacheDomain::Callgraph,
1384                shared_key,
1385                root.path(),
1386            ),
1387            0
1388        );
1389        assert!(!writer_lease_path(&cache_dir).exists());
1390    }
1391
1392    #[cfg(unix)]
1393    #[test]
1394    fn unregistered_root_denies_symlink_aliased_stale_key() {
1395        let storage = tempfile::tempdir().unwrap();
1396        let roots = tempfile::tempdir().unwrap();
1397        let real_parent = roots.path().join("real");
1398        let alias_parent = roots.path().join("alias");
1399        fs::create_dir_all(&real_parent).unwrap();
1400        std::os::unix::fs::symlink(&real_parent, &alias_parent).unwrap();
1401
1402        let root = alias_parent.join("root");
1403        let stale_key = crate::search_index::artifact_cache_key(&root);
1404        fs::create_dir_all(&root).unwrap();
1405        let canonical_key = crate::search_index::artifact_cache_key(&root);
1406        assert_ne!(stale_key, canonical_key, "fixture must exercise the alias");
1407
1408        let cache_dir = storage.path().join("callgraph").join(&stale_key);
1409        enable_writer_lease_acquisition_counts_for_test();
1410        let lease = WriterLease::acquire_shared(
1411            RootCacheDomain::Callgraph,
1412            &cache_dir,
1413            &canonical_key,
1414            &root,
1415        )
1416        .unwrap();
1417
1418        assert!(lease.is_none());
1419        assert_eq!(
1420            writer_lease_acquisition_count_for_test(
1421                RootCacheDomain::Callgraph,
1422                &canonical_key,
1423                &root,
1424            ),
1425            0
1426        );
1427        assert!(!writer_lease_path(&cache_dir).exists());
1428    }
1429
1430    #[cfg(unix)]
1431    #[test]
1432    fn registration_before_directory_creation_survives_alias_canonicalization() {
1433        // Regression for the concurrent-acquire flake: configure registered the
1434        // root before its directory existed (raw alias spelling), the first
1435        // lease's create_dir_all made later lookups canonicalize to the real
1436        // spelling, and the registered root missed its own capability entry.
1437        let storage = tempfile::tempdir().unwrap();
1438        let roots = tempfile::tempdir().unwrap();
1439        let real_parent = roots.path().join("real");
1440        let alias_parent = roots.path().join("alias");
1441        fs::create_dir_all(&real_parent).unwrap();
1442        std::os::unix::fs::symlink(&real_parent, &alias_parent).unwrap();
1443
1444        let root = alias_parent.join("pre-registered");
1445        configure_artifact_access(&root, "pre-registered", false);
1446        fs::create_dir_all(&root).unwrap();
1447
1448        let cache_dir = storage.path().join("inspect").join("pre-registered");
1449        let lease = WriterLease::acquire_shared(
1450            RootCacheDomain::Inspect,
1451            &cache_dir,
1452            "pre-registered",
1453            &root,
1454        )
1455        .unwrap();
1456        assert!(
1457            lease.is_some(),
1458            "a root registered before its directory existed must keep its capability"
1459        );
1460    }
1461
1462    #[test]
1463    fn borrow_only_root_keeps_private_project_scope_writable() {
1464        let storage = tempfile::tempdir().unwrap();
1465        let worktree_root = tempfile::tempdir().unwrap();
1466        let shared_key = "shared-artifact-key";
1467        let private_key = crate::path_identity::project_scope_key(worktree_root.path());
1468        let cache_dir = storage.path().join("inspect").join(&private_key);
1469        configure_artifact_access(worktree_root.path(), shared_key, true);
1470
1471        let lease = WriterLease::acquire_shared(
1472            RootCacheDomain::Inspect,
1473            &cache_dir,
1474            &private_key,
1475            worktree_root.path(),
1476        )
1477        .unwrap()
1478        .expect("private inspect writer lease");
1479
1480        assert!(lease.verify().unwrap());
1481    }
1482
1483    #[test]
1484    fn writer_lease_acquire_shared_does_not_serialize_different_roots() {
1485        let dir = tempfile::tempdir().unwrap();
1486        let blocked_cache_dir = dir.path().join("callgraph").join("blocked");
1487        let free_cache_dir = dir.path().join("callgraph").join("free");
1488        configure_artifact_access(&blocked_cache_dir, "blocked", false);
1489        configure_artifact_access(&free_cache_dir, "free", false);
1490        let (blocked_tx, blocked_rx) = std::sync::mpsc::channel();
1491        let (release_tx, release_rx) = std::sync::mpsc::channel();
1492        let release_rx = Arc::new(Mutex::new(release_rx));
1493
1494        struct HookGuard;
1495        impl Drop for HookGuard {
1496            fn drop(&mut self) {
1497                set_acquire_shared_hook_for_test(None);
1498            }
1499        }
1500
1501        set_acquire_shared_hook_for_test(Some(Arc::new(move |_, _, key| {
1502            if key == "blocked" {
1503                blocked_tx.send(()).unwrap();
1504                release_rx.lock().unwrap().recv().unwrap();
1505            }
1506        })));
1507        let _hook_guard = HookGuard;
1508
1509        let blocked_handle = std::thread::spawn(move || {
1510            WriterLease::acquire_shared(
1511                RootCacheDomain::Callgraph,
1512                &blocked_cache_dir,
1513                "blocked",
1514                &blocked_cache_dir,
1515            )
1516            .map_err(|error| error.to_string())
1517            .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()))
1518        });
1519        blocked_rx
1520            .recv_timeout(Duration::from_secs(5))
1521            .expect("blocked root should reach acquisition hook");
1522
1523        let (free_tx, free_rx) = std::sync::mpsc::channel();
1524        let free_handle = std::thread::spawn(move || {
1525            let result = WriterLease::acquire_shared(
1526                RootCacheDomain::Callgraph,
1527                &free_cache_dir,
1528                "free",
1529                &free_cache_dir,
1530            )
1531            .map_err(|error| error.to_string())
1532            .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()));
1533            free_tx.send(result).unwrap();
1534        });
1535        let free_lease = free_rx
1536            .recv_timeout(Duration::from_secs(5))
1537            .expect("free root should not wait behind another root's acquisition")
1538            .expect("free root should acquire while another root is in acquisition");
1539        assert!(free_lease.verify().unwrap());
1540        free_handle
1541            .join()
1542            .expect("free root thread should not panic");
1543
1544        release_tx.send(()).unwrap();
1545        let blocked_lease = blocked_handle
1546            .join()
1547            .expect("blocked root thread should not panic")
1548            .expect("blocked root should acquire after release");
1549        assert!(blocked_lease.verify().unwrap());
1550    }
1551
1552    #[test]
1553    fn writer_lease_acquire_shared_reuses_single_process_lease_concurrently() {
1554        let dir = tempfile::tempdir().unwrap();
1555        let cache_dir = dir.path().join("inspect").join("project");
1556        configure_artifact_access(&cache_dir, "project", false);
1557        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1558        let mut handles = Vec::new();
1559        for _ in 0..8 {
1560            let barrier = std::sync::Arc::clone(&barrier);
1561            let cache_dir = cache_dir.clone();
1562            handles.push(std::thread::spawn(move || {
1563                barrier.wait();
1564                WriterLease::acquire_shared(
1565                    RootCacheDomain::Inspect,
1566                    &cache_dir,
1567                    "project",
1568                    &cache_dir,
1569                )
1570                .map_err(|error| error.to_string())
1571                .and_then(|lease| {
1572                    lease.ok_or_else(|| "writer lease unexpectedly denied".to_string())
1573                })
1574            }));
1575        }
1576
1577        let leases = handles
1578            .into_iter()
1579            .map(|handle| handle.join().unwrap().unwrap())
1580            .collect::<Vec<_>>();
1581        let epoch = leases[0].epoch().to_string();
1582        let path = leases[0].path().to_path_buf();
1583        for lease in &leases {
1584            assert_eq!(lease.epoch(), epoch);
1585            assert_eq!(lease.path(), path.as_path());
1586            assert!(lease.verify().unwrap());
1587        }
1588    }
1589
1590    #[test]
1591    fn nfs_guard_test_seam_fails_closed() {
1592        set_force_network_fs_for_test(true);
1593        assert!(!storage_allows_root_keyed(Path::new(".")).unwrap());
1594        set_force_network_fs_for_test(false);
1595    }
1596}