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