1use std::collections::{HashMap, HashSet};
20use std::fs::{self, File, OpenOptions};
21use std::io::{self, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::{Arc, Mutex, OnceLock, Weak};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29use crate::fs_lock;
30
31static MARKER_SEQ: AtomicU64 = AtomicU64::new(0);
32
33pub const READ_MARKER_TOUCH_INTERVAL_MS: u64 = fs_lock::HEARTBEAT_INTERVAL_MS;
37pub const READ_MARKER_CROSS_HOST_STALE_MS: u64 = fs_lock::STALE_HEARTBEAT_MS * 5;
40const PROCESS_START_TIME_GRACE_MS: u64 = 1_000;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum RootCacheDomain {
47 Callgraph,
48 Inspect,
49}
50
51impl RootCacheDomain {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 RootCacheDomain::Callgraph => "callgraph",
55 RootCacheDomain::Inspect => "inspect",
56 }
57 }
58}
59
60#[derive(Clone, Default)]
65pub struct ArtifactPublishEpoch {
66 current: Arc<parking_lot::Mutex<u64>>,
67}
68
69impl ArtifactPublishEpoch {
70 pub fn next(&self) -> u64 {
71 let mut current = self.current.lock();
72 *current = current.wrapping_add(1);
73 *current
74 }
75
76 pub fn current(&self) -> u64 {
77 *self.current.lock()
78 }
79
80 pub fn run_if_current<R>(&self, expected: u64, publish: impl FnOnce() -> R) -> Option<R> {
81 let current = self.current.lock();
82 if *current != expected {
83 return None;
84 }
85 Some(publish())
86 }
87}
88
89pub struct WriterLease {
90 domain: RootCacheDomain,
91 key: String,
92 path: PathBuf,
93 epoch: String,
94 guard: Mutex<fs_lock::LockGuard>,
95}
96
97#[derive(Clone, Debug, Eq, Hash, PartialEq)]
98struct ProcessLeaseKey {
99 domain: RootCacheDomain,
100 cache_dir: PathBuf,
101}
102
103#[derive(Clone, Debug, Eq, Hash, PartialEq)]
104struct WriterLeaseAcquisitionKey {
105 domain: RootCacheDomain,
106 key: String,
107 project_root: PathBuf,
108}
109
110static PROCESS_LEASES: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>>> =
111 OnceLock::new();
112static PROCESS_LEASE_ACQUISITIONS: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>>> =
116 OnceLock::new();
117static WRITER_LEASE_ACQUISITION_COUNTS: OnceLock<Mutex<HashMap<WriterLeaseAcquisitionKey, usize>>> =
118 OnceLock::new();
119static WRITER_LEASE_ACQUISITION_COUNTER_ENABLED: AtomicBool = AtomicBool::new(false);
120static CONFIGURED_ARTIFACT_ACCESS: OnceLock<Mutex<HashMap<PathBuf, ArtifactAccess>>> =
121 OnceLock::new();
122static WARNED_BORROW_ONLY_WRITES: OnceLock<Mutex<HashSet<(PathBuf, PathBuf)>>> = OnceLock::new();
123
124#[derive(Clone, Debug)]
127pub struct ArtifactAccess {
128 project_root: PathBuf,
129 shared_key: String,
130 private_key: String,
131 borrow_only_shared: bool,
132}
133
134impl ArtifactAccess {
135 fn configured(project_root: &Path, shared_key: &str, borrow_only_shared: bool) -> Self {
136 let project_root = canonical_root(project_root);
137 Self {
138 private_key: crate::path_identity::project_scope_key(&project_root),
139 project_root,
140 shared_key: shared_key.to_string(),
141 borrow_only_shared,
142 }
143 }
144
145 pub fn for_root(project_root: &Path) -> Self {
148 let project_root = canonical_root(project_root);
149 if let Some(access) = configured_artifact_access()
150 .lock()
151 .ok()
152 .and_then(|access| access.get(&project_root).cloned())
153 {
154 return access;
155 }
156 crate::slog_warn!(
163 "artifact access requested for unconfigured root {}; defaulting to borrow-only",
164 project_root.display()
165 );
166 let shared_key = crate::path_identity::project_scope_key(&project_root);
167 Self::configured(&project_root, &shared_key, true)
168 }
169
170 pub fn allows_write(&self, artifact_key: &str, write_path: &Path) -> bool {
173 let writes_keyed_dir = write_path
174 .parent()
175 .and_then(Path::file_name)
176 .and_then(|name| name.to_str())
177 .is_some_and(|name| name == artifact_key);
178 if !self.borrow_only_shared
179 || artifact_key != self.shared_key
180 || artifact_key == self.private_key
181 || !writes_keyed_dir
182 {
183 return true;
184 }
185 let warning_key = (self.project_root.clone(), write_path.to_path_buf());
186 let should_warn = WARNED_BORROW_ONLY_WRITES
187 .get_or_init(|| Mutex::new(HashSet::new()))
188 .lock()
189 .map(|mut warned| {
190 if warned.len() >= 4_096 {
191 warned.clear();
192 }
193 warned.insert(warning_key)
194 })
195 .unwrap_or(false);
196 if should_warn {
197 crate::slog_warn!(
198 "borrow-only worktree denied shared artifact write at {}",
199 write_path.display()
200 );
201 }
202 false
203 }
204}
205
206fn configured_artifact_access() -> &'static Mutex<HashMap<PathBuf, ArtifactAccess>> {
207 CONFIGURED_ARTIFACT_ACCESS.get_or_init(|| Mutex::new(HashMap::new()))
208}
209
210pub fn configure_artifact_access(project_root: &Path, shared_key: &str, borrow_only_shared: bool) {
213 let access = ArtifactAccess::configured(project_root, shared_key, borrow_only_shared);
214 if let Ok(mut configured) = configured_artifact_access().lock() {
215 if configured.len() >= 4_096 && !configured.contains_key(&access.project_root) {
220 if let Some(evict) = configured.keys().next().cloned() {
221 configured.remove(&evict);
222 }
223 }
224 configured.insert(access.project_root.clone(), access);
225 }
226}
227
228fn canonical_root(project_root: &Path) -> PathBuf {
229 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf())
230}
231
232fn process_leases() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>> {
233 PROCESS_LEASES.get_or_init(|| Mutex::new(HashMap::new()))
234}
235
236fn process_lease_acquisitions() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>> {
237 PROCESS_LEASE_ACQUISITIONS.get_or_init(|| Mutex::new(HashMap::new()))
238}
239
240fn writer_lease_acquisition_counts() -> &'static Mutex<HashMap<WriterLeaseAcquisitionKey, usize>> {
241 WRITER_LEASE_ACQUISITION_COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
242}
243
244fn record_writer_lease_acquisition(domain: RootCacheDomain, key: &str, project_root: &Path) {
245 if !WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.load(Ordering::Relaxed) {
246 return;
247 }
248 let project_root =
249 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
250 let acquisition_key = WriterLeaseAcquisitionKey {
251 domain,
252 key: key.to_string(),
253 project_root,
254 };
255 if let Ok(mut counts) = writer_lease_acquisition_counts().lock() {
256 *counts.entry(acquisition_key).or_default() += 1;
257 }
258}
259
260#[doc(hidden)]
261pub fn reset_writer_lease_acquisition_counts_for_test() {
262 WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.store(true, Ordering::Relaxed);
263 if let Ok(mut counts) = writer_lease_acquisition_counts().lock() {
264 counts.clear();
265 }
266}
267
268#[doc(hidden)]
269pub fn writer_lease_acquisition_count_for_test(
270 domain: RootCacheDomain,
271 key: &str,
272 project_root: &Path,
273) -> usize {
274 let project_root =
275 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
276 writer_lease_acquisition_counts()
277 .lock()
278 .ok()
279 .and_then(|counts| {
280 counts
281 .get(&WriterLeaseAcquisitionKey {
282 domain,
283 key: key.to_string(),
284 project_root,
285 })
286 .copied()
287 })
288 .unwrap_or(0)
289}
290
291fn shared_process_lease(
292 registry_key: &ProcessLeaseKey,
293) -> Result<Option<Arc<WriterLease>>, fs_lock::AcquireError> {
294 let mut leases = process_leases().lock().map_err(|_| {
295 fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
296 })?;
297 if let Some(existing) = leases.get(registry_key).and_then(Weak::upgrade) {
298 if existing.verify()? {
299 return Ok(Some(existing));
300 }
301 leases.remove(registry_key);
302 }
303 Ok(None)
304}
305
306fn process_lease_acquisition_lock(
307 registry_key: &ProcessLeaseKey,
308) -> Result<Arc<Mutex<()>>, fs_lock::AcquireError> {
309 let mut acquisitions = process_lease_acquisitions().lock().map_err(|_| {
310 fs_lock::AcquireError::Io(io::Error::other(
311 "process lease acquisition registry poisoned",
312 ))
313 })?;
314 if let Some(existing) = acquisitions.get(registry_key).and_then(Weak::upgrade) {
315 return Ok(existing);
316 }
317 if acquisitions.len() > 1024 {
318 acquisitions.retain(|_, lock| lock.strong_count() > 0);
319 }
320 let lock = Arc::new(Mutex::new(()));
321 acquisitions.insert(registry_key.clone(), Arc::downgrade(&lock));
322 Ok(lock)
323}
324
325#[cfg(test)]
326type AcquireSharedHook = Arc<dyn Fn(RootCacheDomain, &Path, &str) + Send + Sync + 'static>;
327
328#[cfg(test)]
329static ACQUIRE_SHARED_HOOK: OnceLock<Mutex<Option<AcquireSharedHook>>> = OnceLock::new();
330
331#[cfg(test)]
332fn set_acquire_shared_hook_for_test(hook: Option<AcquireSharedHook>) {
333 *ACQUIRE_SHARED_HOOK
334 .get_or_init(|| Mutex::new(None))
335 .lock()
336 .expect("acquire shared hook mutex") = hook;
337}
338
339#[cfg(test)]
340fn run_acquire_shared_hook_for_test(domain: RootCacheDomain, cache_dir: &Path, key: &str) {
341 let hook = ACQUIRE_SHARED_HOOK
342 .get_or_init(|| Mutex::new(None))
343 .lock()
344 .expect("acquire shared hook mutex")
345 .clone();
346 if let Some(hook) = hook {
347 hook(domain, cache_dir, key);
348 }
349}
350
351#[cfg(not(test))]
352fn run_acquire_shared_hook_for_test(_domain: RootCacheDomain, _cache_dir: &Path, _key: &str) {}
353
354impl std::fmt::Debug for WriterLease {
355 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 formatter
357 .debug_struct("WriterLease")
358 .field("domain", &self.domain)
359 .field("key", &self.key)
360 .field("path", &self.path)
361 .field("epoch", &self.epoch)
362 .finish_non_exhaustive()
363 }
364}
365
366impl WriterLease {
367 pub fn acquire_shared(
368 domain: RootCacheDomain,
369 cache_dir: &Path,
370 key: &str,
371 project_root: &Path,
372 ) -> Result<Option<Arc<Self>>, fs_lock::AcquireError> {
373 let access = ArtifactAccess::for_root(project_root);
374 if !access.allows_write(key, &writer_lease_path(cache_dir)) {
375 return Ok(None);
376 }
377 let registry_key = ProcessLeaseKey {
378 domain,
379 cache_dir: canonical_process_lease_dir(cache_dir),
380 };
381 if let Some(existing) = shared_process_lease(®istry_key)? {
382 record_writer_lease_acquisition(domain, key, project_root);
383 return Ok(Some(existing));
384 }
385
386 let acquisition_lock = process_lease_acquisition_lock(®istry_key)?;
387 let _acquisition_guard = acquisition_lock.lock().map_err(|_| {
388 fs_lock::AcquireError::Io(io::Error::other("process lease acquisition poisoned"))
389 })?;
390
391 if let Some(existing) = shared_process_lease(®istry_key)? {
392 record_writer_lease_acquisition(domain, key, project_root);
393 return Ok(Some(existing));
394 }
395
396 run_acquire_shared_hook_for_test(domain, cache_dir, key);
397
398 let lease = Arc::new(Self::acquire(domain, cache_dir, key, Duration::ZERO)?);
399 process_leases()
400 .lock()
401 .map_err(|_| {
402 fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
403 })?
404 .insert(registry_key, Arc::downgrade(&lease));
405 record_writer_lease_acquisition(domain, key, project_root);
406 Ok(Some(lease))
407 }
408
409 fn acquire(
410 domain: RootCacheDomain,
411 cache_dir: &Path,
412 key: &str,
413 timeout: Duration,
414 ) -> Result<Self, fs_lock::AcquireError> {
415 if !storage_allows_root_keyed(cache_dir)? {
416 return Err(fs_lock::AcquireError::Io(io::Error::new(
417 io::ErrorKind::PermissionDenied,
418 format!(
419 "refusing root-keyed {} writer lease on a network filesystem at {}",
420 domain.as_str(),
421 cache_dir.display()
422 ),
423 )));
424 }
425 if let Some(storage_root) = cache_dir.parent().and_then(Path::parent) {
426 crate::legacy_partitions::guard_new_layout_write_path(
427 storage_root,
428 cache_dir,
429 "root-keyed writer lease",
430 )?;
431 }
432 fs::create_dir_all(cache_dir)?;
433 let guard = fs_lock::try_acquire(&writer_lease_path(cache_dir), timeout)?;
434 if !guard.verify_writer_epoch()? {
435 return Err(fs_lock::AcquireError::Io(io::Error::other(
436 "writer lease epoch changed immediately after acquisition",
437 )));
438 }
439 let path = guard.path().to_path_buf();
440 let epoch = guard.writer_epoch().to_string();
441 Ok(Self {
442 domain,
443 key: key.to_string(),
444 path,
445 epoch,
446 guard: Mutex::new(guard),
447 })
448 }
449
450 pub fn verify(&self) -> io::Result<bool> {
451 self.guard
452 .lock()
453 .map_err(|_| io::Error::other("writer lease mutex poisoned"))?
454 .verify_writer_epoch()
455 }
456
457 pub fn epoch(&self) -> &str {
458 &self.epoch
459 }
460
461 pub fn domain(&self) -> RootCacheDomain {
462 self.domain
463 }
464
465 pub fn key(&self) -> &str {
466 &self.key
467 }
468
469 pub fn path(&self) -> &Path {
470 &self.path
471 }
472}
473
474pub fn writer_lease_path(cache_dir: &Path) -> PathBuf {
475 cache_dir.join("writer.lease")
476}
477
478#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
479pub struct ReadMarkerMetadata {
480 pub pid: u32,
481 pub hostname: String,
482 pub created_at_ms: u64,
483}
484
485#[derive(Debug)]
486pub struct ReadMarker {
487 path: PathBuf,
488 metadata: ReadMarkerMetadata,
489 last_touched_at_ms: AtomicU64,
490}
491
492impl ReadMarker {
493 pub fn create(cache_dir: &Path, generation_label: &str) -> io::Result<Self> {
494 let metadata = ReadMarkerMetadata {
495 pid: std::process::id(),
496 hostname: current_hostname(),
497 created_at_ms: now_ms(),
498 };
499 let dir = read_marker_dir(cache_dir, generation_label);
500 fs::create_dir_all(&dir)?;
501 let seq = MARKER_SEQ.fetch_add(1, Ordering::Relaxed);
502 let path = dir.join(format!(
503 "{}.{}.{}.{}.json",
504 metadata.pid,
505 sanitize_marker_component(&metadata.hostname),
506 metadata.created_at_ms,
507 seq
508 ));
509 write_marker_file(&path, &metadata)?;
510 let last_touched_at_ms = AtomicU64::new(metadata.created_at_ms);
511 Ok(Self {
512 path,
513 metadata,
514 last_touched_at_ms,
515 })
516 }
517
518 pub fn touch(&self) -> io::Result<()> {
519 write_marker_file(&self.path, &self.metadata)?;
520 self.last_touched_at_ms.store(now_ms(), Ordering::Relaxed);
521 Ok(())
522 }
523
524 pub fn touch_if_due(&self) -> io::Result<()> {
525 let now = now_ms();
526 let last = self.last_touched_at_ms.load(Ordering::Relaxed);
527 if now.saturating_sub(last) < READ_MARKER_TOUCH_INTERVAL_MS {
528 return Ok(());
529 }
530 self.touch()
531 }
532
533 pub fn path(&self) -> &Path {
534 &self.path
535 }
536
537 pub fn metadata(&self) -> &ReadMarkerMetadata {
538 &self.metadata
539 }
540}
541
542impl Drop for ReadMarker {
543 fn drop(&mut self) {
544 let _ = fs::remove_file(&self.path);
545 fs_lock::sync_parent(&self.path);
546 }
547}
548
549pub fn read_marker_dir(cache_dir: &Path, generation_label: &str) -> PathBuf {
550 cache_dir.join("readers").join(generation_label)
551}
552
553#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
554pub struct ReadMarkerSweep {
555 pub protected: bool,
556 pub removed_stale: usize,
557}
558
559pub fn protected_read_marker_exists(cache_dir: &Path, generation_label: &str) -> bool {
560 read_marker_protection(cache_dir, generation_label, false).protected
561}
562
563pub fn sweep_read_markers(cache_dir: &Path, generation_label: &str) -> ReadMarkerSweep {
564 read_marker_protection(cache_dir, generation_label, true)
565}
566
567fn read_marker_protection(
568 cache_dir: &Path,
569 generation_label: &str,
570 remove_stale: bool,
571) -> ReadMarkerSweep {
572 let dir = read_marker_dir(cache_dir, generation_label);
573 let entries = match fs::read_dir(&dir) {
574 Ok(entries) => entries,
575 Err(error) if error.kind() == io::ErrorKind::NotFound => return ReadMarkerSweep::default(),
576 Err(_) => {
577 return ReadMarkerSweep {
578 protected: true,
579 removed_stale: 0,
580 };
581 }
582 };
583
584 let hostname = current_hostname();
585 let now = now_ms();
586 let mut sweep = ReadMarkerSweep::default();
587 for entry in entries.flatten() {
588 let path = entry.path();
589 match marker_file_is_protected(&path, now, &hostname) {
590 MarkerProtection::Protected => sweep.protected = true,
591 MarkerProtection::Stale | MarkerProtection::Malformed => {
592 if remove_stale && fs::remove_file(&path).is_ok() {
593 fs_lock::sync_parent(&path);
594 sweep.removed_stale += 1;
595 }
596 }
597 }
598 }
599 sweep
600}
601
602#[derive(Clone, Copy, Debug, PartialEq, Eq)]
603enum MarkerProtection {
604 Protected,
605 Stale,
606 Malformed,
607}
608
609fn marker_file_is_protected(path: &Path, now: u64, current_host: &str) -> MarkerProtection {
610 let bytes = match fs::read(path) {
611 Ok(bytes) => bytes,
612 Err(error) if error.kind() == io::ErrorKind::NotFound => return MarkerProtection::Stale,
613 Err(_) => return MarkerProtection::Protected,
614 };
615 let metadata: ReadMarkerMetadata = match serde_json::from_slice(&bytes) {
616 Ok(metadata) => metadata,
617 Err(_) => return MarkerProtection::Malformed,
618 };
619 if metadata.hostname != current_host {
620 let Ok(file_metadata) = fs::metadata(path) else {
621 return MarkerProtection::Protected;
622 };
623 let mtime_ms = file_metadata
624 .modified()
625 .ok()
626 .map(system_time_ms)
627 .unwrap_or(now);
628 let age_ms = now.saturating_sub(mtime_ms);
629 return if age_ms <= READ_MARKER_CROSS_HOST_STALE_MS {
630 MarkerProtection::Protected
631 } else {
632 MarkerProtection::Stale
633 };
634 }
635
636 if !fs_lock::process_alive(metadata.pid) {
637 return MarkerProtection::Stale;
638 }
639 if marker_matches_live_process_instance(&metadata) {
640 MarkerProtection::Protected
641 } else {
642 MarkerProtection::Stale
643 }
644}
645
646fn marker_matches_live_process_instance(metadata: &ReadMarkerMetadata) -> bool {
647 process_start_time_ms(metadata.pid)
652 .map(|started_at_ms| {
653 started_at_ms
654 <= metadata
655 .created_at_ms
656 .saturating_add(PROCESS_START_TIME_GRACE_MS)
657 })
658 .unwrap_or(true)
659}
660
661fn write_marker_file(path: &Path, metadata: &ReadMarkerMetadata) -> io::Result<()> {
662 let tmp = path.with_file_name(format!(
663 ".{}.tmp.{}.{}",
664 path.file_name()
665 .and_then(|name| name.to_str())
666 .unwrap_or("reader"),
667 std::process::id(),
668 now_nanos()
669 ));
670 let result = (|| {
671 let mut file = open_private_file(&tmp)?;
672 serde_json::to_writer(&mut file, metadata).map_err(io::Error::other)?;
673 file.write_all(b"\n")?;
674 file.sync_all()?;
675 drop(file);
676 fs_lock::rename_over(&tmp, path)?;
677 fs_lock::sync_parent(path);
678 Ok(())
679 })();
680 if result.is_err() {
681 let _ = fs::remove_file(&tmp);
682 }
683 result
684}
685
686#[cfg(unix)]
687fn open_private_file(path: &Path) -> io::Result<File> {
688 use std::os::unix::fs::OpenOptionsExt;
689
690 OpenOptions::new()
691 .write(true)
692 .create_new(true)
693 .mode(0o600)
694 .open(path)
695}
696
697#[cfg(not(unix))]
698fn open_private_file(path: &Path) -> io::Result<File> {
699 OpenOptions::new().write(true).create_new(true).open(path)
700}
701
702fn sanitize_marker_component(value: &str) -> String {
703 value
704 .chars()
705 .map(|ch| {
706 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
707 ch
708 } else {
709 '_'
710 }
711 })
712 .collect()
713}
714
715#[cfg(test)]
716static FORCE_NETWORK_FS_FOR_TEST: AtomicBool = AtomicBool::new(false);
717
718#[cfg(test)]
719pub fn set_force_network_fs_for_test(enabled: bool) {
720 FORCE_NETWORK_FS_FOR_TEST.store(enabled, Ordering::SeqCst);
721}
722
723pub fn storage_allows_root_keyed(path: &Path) -> io::Result<bool> {
724 #[cfg(test)]
725 if FORCE_NETWORK_FS_FOR_TEST.load(Ordering::SeqCst) {
726 return Ok(false);
727 }
728
729 let probe = existing_ancestor(path);
730 filesystem_is_local(&probe)
731}
732
733fn canonical_process_lease_dir(path: &Path) -> PathBuf {
734 if let Ok(canonical) = std::fs::canonicalize(path) {
735 return canonical;
736 }
737
738 let normalized = lexical_normalize(path);
739 let mut missing_components = Vec::new();
740 let mut current = normalized.as_path();
741 while !current.exists() {
742 let Some(name) = current.file_name() else {
743 return normalized;
744 };
745 missing_components.push(name.to_os_string());
746 let Some(parent) = current.parent() else {
747 return normalized;
748 };
749 current = parent;
750 }
751
752 let mut canonical = std::fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf());
753 for component in missing_components.iter().rev() {
754 canonical.push(component);
755 }
756 canonical
757}
758
759fn lexical_normalize(path: &Path) -> PathBuf {
760 let mut normalized = PathBuf::new();
761 for component in path.components() {
762 match component {
763 std::path::Component::CurDir => {}
764 std::path::Component::ParentDir => {
765 normalized.pop();
766 }
767 other => normalized.push(other.as_os_str()),
768 }
769 }
770 normalized
771}
772
773fn existing_ancestor(path: &Path) -> PathBuf {
774 let mut current = path;
775 loop {
776 if current.exists() {
777 return current.to_path_buf();
778 }
779 let Some(parent) = current.parent() else {
780 return PathBuf::from(".");
781 };
782 current = parent;
783 }
784}
785
786#[cfg(target_os = "macos")]
787fn filesystem_is_local(path: &Path) -> io::Result<bool> {
788 use std::ffi::CString;
789 use std::os::unix::ffi::OsStrExt;
790
791 let c_path = CString::new(path.as_os_str().as_bytes())
792 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
793 let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
794 if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
795 return Err(io::Error::last_os_error());
796 }
797 let nul = stat
798 .f_fstypename
799 .iter()
800 .position(|byte| *byte == 0)
801 .unwrap_or(stat.f_fstypename.len());
802 let fs_type = String::from_utf8_lossy(
803 &stat.f_fstypename[..nul]
804 .iter()
805 .map(|byte| *byte as u8)
806 .collect::<Vec<_>>(),
807 )
808 .to_ascii_lowercase();
809 Ok(!matches!(
810 fs_type.as_str(),
811 "nfs" | "smbfs" | "afpfs" | "webdav" | "fusefs"
812 ))
813}
814
815#[cfg(all(unix, not(target_os = "macos")))]
816fn filesystem_is_local(path: &Path) -> io::Result<bool> {
817 use std::ffi::CString;
818 use std::os::unix::ffi::OsStrExt;
819
820 let c_path = CString::new(path.as_os_str().as_bytes())
821 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
822 let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
823 if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
824 return Err(io::Error::last_os_error());
825 }
826 let fs_type = stat.f_type as i64;
827 const NFS_SUPER_MAGIC: i64 = 0x6969;
828 const SMB_SUPER_MAGIC: i64 = 0x517B;
829 const CIFS_MAGIC_NUMBER: i64 = 0xFF534D42;
830 Ok(!matches!(
831 fs_type,
832 NFS_SUPER_MAGIC | SMB_SUPER_MAGIC | CIFS_MAGIC_NUMBER
833 ))
834}
835
836#[cfg(not(unix))]
837fn filesystem_is_local(_path: &Path) -> io::Result<bool> {
838 Ok(true)
839}
840
841fn now_ms() -> u64 {
842 system_time_ms(SystemTime::now())
843}
844
845fn system_time_ms(time: SystemTime) -> u64 {
846 time.duration_since(UNIX_EPOCH)
847 .unwrap_or(Duration::ZERO)
848 .as_millis() as u64
849}
850
851fn now_nanos() -> u128 {
852 SystemTime::now()
853 .duration_since(UNIX_EPOCH)
854 .unwrap_or(Duration::ZERO)
855 .as_nanos()
856}
857
858#[cfg(test)]
859static PROCESS_START_TIME_OVERRIDES: OnceLock<Mutex<HashMap<u32, Option<u64>>>> = OnceLock::new();
860
861#[cfg(test)]
862fn set_process_start_time_for_test(pid: u32, started_at_ms: Option<u64>) {
863 PROCESS_START_TIME_OVERRIDES
864 .get_or_init(|| Mutex::new(HashMap::new()))
865 .lock()
866 .expect("process start override mutex")
867 .insert(pid, started_at_ms);
868}
869
870#[cfg(test)]
871fn clear_process_start_time_for_test(pid: u32) {
872 if let Some(overrides) = PROCESS_START_TIME_OVERRIDES.get() {
873 overrides
874 .lock()
875 .expect("process start override mutex")
876 .remove(&pid);
877 }
878}
879
880#[cfg(test)]
881fn process_start_time_override(pid: u32) -> Option<Option<u64>> {
882 PROCESS_START_TIME_OVERRIDES
883 .get()
884 .and_then(|overrides| overrides.lock().ok()?.get(&pid).copied())
885}
886
887#[cfg(target_os = "linux")]
888fn process_start_time_ms(pid: u32) -> Option<u64> {
889 #[cfg(test)]
890 if let Some(override_value) = process_start_time_override(pid) {
891 return override_value;
892 }
893
894 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
895 let after_comm = stat.rsplit_once(") ")?.1;
896 let fields = after_comm.split_whitespace().collect::<Vec<_>>();
897 let start_ticks = fields.get(19)?.parse::<u64>().ok()?;
898 let boot_time_secs = fs::read_to_string("/proc/stat")
899 .ok()?
900 .lines()
901 .find_map(|line| line.strip_prefix("btime ")?.parse::<u64>().ok())?;
902 let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
903 if ticks_per_second <= 0 {
904 return None;
905 }
906 let ticks_per_second = ticks_per_second as u64;
907 Some(
908 boot_time_secs
909 .saturating_mul(1_000)
910 .saturating_add(start_ticks.saturating_mul(1_000) / ticks_per_second),
911 )
912}
913
914#[cfg(target_os = "macos")]
915fn process_start_time_ms(pid: u32) -> Option<u64> {
916 #[cfg(test)]
917 if let Some(override_value) = process_start_time_override(pid) {
918 return override_value;
919 }
920
921 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
922 let info_size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
923 let bytes = unsafe {
924 libc::proc_pidinfo(
925 pid as libc::c_int,
926 libc::PROC_PIDTBSDINFO,
927 0,
928 (&mut info as *mut libc::proc_bsdinfo).cast(),
929 info_size,
930 )
931 };
932 if bytes != info_size {
933 return None;
934 }
935 Some(
936 info.pbi_start_tvsec
937 .saturating_mul(1_000)
938 .saturating_add(info.pbi_start_tvusec / 1_000),
939 )
940}
941
942#[cfg(not(any(target_os = "linux", target_os = "macos")))]
943fn process_start_time_ms(pid: u32) -> Option<u64> {
944 #[cfg(test)]
945 if let Some(override_value) = process_start_time_override(pid) {
946 return override_value;
947 }
948 let _ = pid;
949 None
950}
951
952#[cfg(unix)]
953fn current_hostname() -> String {
954 let mut buffer = [0u8; 256];
955 let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
956 if result == 0 {
957 let len = buffer
958 .iter()
959 .position(|byte| *byte == 0)
960 .unwrap_or(buffer.len());
961 if len > 0 {
962 return String::from_utf8_lossy(&buffer[..len]).into_owned();
963 }
964 }
965 "unknown-host".to_string()
966}
967
968#[cfg(windows)]
969fn current_hostname() -> String {
970 std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown-host".to_string())
971}
972
973#[cfg(all(not(unix), not(windows)))]
974fn current_hostname() -> String {
975 "unknown-host".to_string()
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 #[test]
983 fn read_marker_file_is_private_and_touchable() {
984 let dir = tempfile::tempdir().unwrap();
985 let marker = ReadMarker::create(dir.path(), "current").unwrap();
986 assert!(marker.path().is_file());
987 marker.touch().unwrap();
988 let bytes = fs::read(marker.path()).unwrap();
989 let parsed: ReadMarkerMetadata = serde_json::from_slice(&bytes).unwrap();
990 assert_eq!(parsed.pid, std::process::id());
991 #[cfg(unix)]
992 {
993 use std::os::unix::fs::PermissionsExt;
994 assert_eq!(
995 fs::metadata(marker.path()).unwrap().permissions().mode() & 0o777,
996 0o600
997 );
998 }
999 }
1000
1001 #[test]
1002 fn same_host_live_marker_ignores_stale_mtime() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let marker = ReadMarker::create(dir.path(), "current").unwrap();
1005 filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
1006
1007 assert!(protected_read_marker_exists(dir.path(), "current"));
1008 }
1009
1010 #[test]
1011 fn sweep_removes_dead_same_host_marker() {
1012 let dir = tempfile::tempdir().unwrap();
1013 let marker_path = read_marker_dir(dir.path(), "old").join("dead.json");
1014 let metadata = ReadMarkerMetadata {
1015 pid: 0,
1016 hostname: current_hostname(),
1017 created_at_ms: now_ms(),
1018 };
1019 fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1020 write_marker_file(&marker_path, &metadata).unwrap();
1021
1022 let sweep = sweep_read_markers(dir.path(), "old");
1023
1024 assert!(!sweep.protected);
1025 assert_eq!(sweep.removed_stale, 1);
1026 assert!(!marker_path.exists());
1027 }
1028
1029 #[test]
1030 fn sweep_removes_reused_pid_marker_when_created_at_predates_process_start() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let marker_path = read_marker_dir(dir.path(), "old").join("reused.json");
1033 let pid = std::process::id();
1034 let metadata = ReadMarkerMetadata {
1035 pid,
1036 hostname: current_hostname(),
1037 created_at_ms: 1_000,
1038 };
1039 fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1040 write_marker_file(&marker_path, &metadata).unwrap();
1041 set_process_start_time_for_test(pid, Some(10_000));
1042
1043 let sweep = sweep_read_markers(dir.path(), "old");
1044 clear_process_start_time_for_test(pid);
1045
1046 assert!(!sweep.protected);
1047 assert_eq!(sweep.removed_stale, 1);
1048 assert!(!marker_path.exists());
1049 }
1050
1051 #[test]
1052 fn sweep_removes_expired_cross_host_marker() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let marker_path = read_marker_dir(dir.path(), "old").join("cross-host.json");
1055 let metadata = ReadMarkerMetadata {
1056 pid: 123,
1057 hostname: format!("other-{}", current_hostname()),
1058 created_at_ms: now_ms(),
1059 };
1060 fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1061 write_marker_file(&marker_path, &metadata).unwrap();
1062 let stale_time = SystemTime::now()
1063 .checked_sub(Duration::from_millis(
1064 READ_MARKER_CROSS_HOST_STALE_MS.saturating_add(1_000),
1065 ))
1066 .unwrap_or(UNIX_EPOCH);
1067 filetime::set_file_mtime(
1068 &marker_path,
1069 filetime::FileTime::from_system_time(stale_time),
1070 )
1071 .unwrap();
1072
1073 let sweep = sweep_read_markers(dir.path(), "old");
1074
1075 assert!(!sweep.protected);
1076 assert_eq!(sweep.removed_stale, 1);
1077 assert!(!marker_path.exists());
1078 }
1079
1080 #[cfg(unix)]
1081 #[test]
1082 fn process_lease_dir_canonicalizes_existing_ancestor_before_cache_dir_exists() {
1083 let dir = tempfile::tempdir().unwrap();
1084 let real = dir.path().join("real");
1085 let link = dir.path().join("link");
1086 fs::create_dir_all(&real).unwrap();
1087 std::os::unix::fs::symlink(&real, &link).unwrap();
1088
1089 let missing_cache_dir = link.join("inspect").join("project");
1090 let before_create = canonical_process_lease_dir(&missing_cache_dir);
1091 fs::create_dir_all(&missing_cache_dir).unwrap();
1092 let after_create = canonical_process_lease_dir(&missing_cache_dir);
1093
1094 assert_eq!(before_create, after_create);
1095 assert!(before_create.starts_with(std::fs::canonicalize(&real).unwrap()));
1096 }
1097
1098 #[test]
1099 fn borrow_only_root_never_receives_existing_shared_writer_capability() {
1100 let storage = tempfile::tempdir().unwrap();
1101 let parent_root = tempfile::tempdir().unwrap();
1102 let worktree_root = tempfile::tempdir().unwrap();
1103 let shared_key = "shared-artifact-key";
1104 let cache_dir = storage.path().join("callgraph").join(shared_key);
1105 configure_artifact_access(parent_root.path(), shared_key, false);
1106 configure_artifact_access(worktree_root.path(), shared_key, true);
1107
1108 let parent_lease = WriterLease::acquire_shared(
1109 RootCacheDomain::Callgraph,
1110 &cache_dir,
1111 shared_key,
1112 parent_root.path(),
1113 )
1114 .unwrap()
1115 .expect("parent writer lease");
1116 reset_writer_lease_acquisition_counts_for_test();
1117
1118 let worktree_lease = WriterLease::acquire_shared(
1119 RootCacheDomain::Callgraph,
1120 &cache_dir,
1121 shared_key,
1122 worktree_root.path(),
1123 )
1124 .unwrap();
1125
1126 assert!(worktree_lease.is_none());
1127 assert!(parent_lease.verify().unwrap());
1128 assert_eq!(
1129 writer_lease_acquisition_count_for_test(
1130 RootCacheDomain::Callgraph,
1131 shared_key,
1132 worktree_root.path(),
1133 ),
1134 0
1135 );
1136 }
1137
1138 #[test]
1139 fn borrow_only_root_keeps_private_project_scope_writable() {
1140 let storage = tempfile::tempdir().unwrap();
1141 let worktree_root = tempfile::tempdir().unwrap();
1142 let shared_key = "shared-artifact-key";
1143 let private_key = crate::path_identity::project_scope_key(worktree_root.path());
1144 let cache_dir = storage.path().join("inspect").join(&private_key);
1145 configure_artifact_access(worktree_root.path(), shared_key, true);
1146
1147 let lease = WriterLease::acquire_shared(
1148 RootCacheDomain::Inspect,
1149 &cache_dir,
1150 &private_key,
1151 worktree_root.path(),
1152 )
1153 .unwrap()
1154 .expect("private inspect writer lease");
1155
1156 assert!(lease.verify().unwrap());
1157 }
1158
1159 #[test]
1160 fn writer_lease_acquire_shared_does_not_serialize_different_roots() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let blocked_cache_dir = dir.path().join("callgraph").join("blocked");
1163 let free_cache_dir = dir.path().join("callgraph").join("free");
1164 let (blocked_tx, blocked_rx) = std::sync::mpsc::channel();
1165 let (release_tx, release_rx) = std::sync::mpsc::channel();
1166 let release_rx = Arc::new(Mutex::new(release_rx));
1167
1168 struct HookGuard;
1169 impl Drop for HookGuard {
1170 fn drop(&mut self) {
1171 set_acquire_shared_hook_for_test(None);
1172 }
1173 }
1174
1175 set_acquire_shared_hook_for_test(Some(Arc::new(move |_, _, key| {
1176 if key == "blocked" {
1177 blocked_tx.send(()).unwrap();
1178 release_rx.lock().unwrap().recv().unwrap();
1179 }
1180 })));
1181 let _hook_guard = HookGuard;
1182
1183 let blocked_handle = std::thread::spawn(move || {
1184 WriterLease::acquire_shared(
1185 RootCacheDomain::Callgraph,
1186 &blocked_cache_dir,
1187 "blocked",
1188 &blocked_cache_dir,
1189 )
1190 .map_err(|error| error.to_string())
1191 .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()))
1192 });
1193 blocked_rx
1194 .recv_timeout(Duration::from_secs(5))
1195 .expect("blocked root should reach acquisition hook");
1196
1197 let (free_tx, free_rx) = std::sync::mpsc::channel();
1198 let free_handle = std::thread::spawn(move || {
1199 let result = WriterLease::acquire_shared(
1200 RootCacheDomain::Callgraph,
1201 &free_cache_dir,
1202 "free",
1203 &free_cache_dir,
1204 )
1205 .map_err(|error| error.to_string())
1206 .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()));
1207 free_tx.send(result).unwrap();
1208 });
1209 let free_lease = free_rx
1210 .recv_timeout(Duration::from_secs(5))
1211 .expect("free root should not wait behind another root's acquisition")
1212 .expect("free root should acquire while another root is in acquisition");
1213 assert!(free_lease.verify().unwrap());
1214 free_handle
1215 .join()
1216 .expect("free root thread should not panic");
1217
1218 release_tx.send(()).unwrap();
1219 let blocked_lease = blocked_handle
1220 .join()
1221 .expect("blocked root thread should not panic")
1222 .expect("blocked root should acquire after release");
1223 assert!(blocked_lease.verify().unwrap());
1224 }
1225
1226 #[test]
1227 fn writer_lease_acquire_shared_reuses_single_process_lease_concurrently() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let cache_dir = dir.path().join("inspect").join("project");
1230 let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1231 let mut handles = Vec::new();
1232 for _ in 0..8 {
1233 let barrier = std::sync::Arc::clone(&barrier);
1234 let cache_dir = cache_dir.clone();
1235 handles.push(std::thread::spawn(move || {
1236 barrier.wait();
1237 WriterLease::acquire_shared(
1238 RootCacheDomain::Inspect,
1239 &cache_dir,
1240 "project",
1241 &cache_dir,
1242 )
1243 .map_err(|error| error.to_string())
1244 .and_then(|lease| {
1245 lease.ok_or_else(|| "writer lease unexpectedly denied".to_string())
1246 })
1247 }));
1248 }
1249
1250 let leases = handles
1251 .into_iter()
1252 .map(|handle| handle.join().unwrap().unwrap())
1253 .collect::<Vec<_>>();
1254 let epoch = leases[0].epoch().to_string();
1255 let path = leases[0].path().to_path_buf();
1256 for lease in &leases {
1257 assert_eq!(lease.epoch(), epoch);
1258 assert_eq!(lease.path(), path.as_path());
1259 assert!(lease.verify().unwrap());
1260 }
1261 }
1262
1263 #[test]
1264 fn nfs_guard_test_seam_fails_closed() {
1265 set_force_network_fs_for_test(true);
1266 assert!(!storage_allows_root_keyed(Path::new(".")).unwrap());
1267 set_force_network_fs_for_test(false);
1268 }
1269}