1use std::fs::{self, File};
2use std::io::{BufReader, BufWriter, Read, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6#[cfg(test)]
7use std::sync::OnceLock;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use crate::fs_lock;
11use crate::parser::SymbolCache;
12use crate::search_index::{cache_relative_path, validate_cached_relative_path};
13use crate::slog_warn;
14use crate::symbols::Symbol;
15
16const MAGIC: &[u8; 8] = b"AFTSYM1\0";
17const FORMAT_VERSION: u32 = 3;
18
19pub const SCHEMA_VERSION: u32 = 3;
25
26const MAX_ENTRIES: usize = 2_000_000;
27const MAX_PATH_BYTES: usize = 16 * 1024;
28const MAX_SYMBOL_BYTES: usize = 16 * 1024 * 1024;
29static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
30static SYMBOL_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
31#[cfg(test)]
32static WATCHED_CACHE_WRITE: OnceLock<Mutex<Option<(PathBuf, usize)>>> = OnceLock::new();
33
34pub struct SymbolCacheLock {
35 _guard: Option<fs_lock::LockGuard>,
36}
37
38impl SymbolCacheLock {
39 pub fn acquire(
40 storage_dir: &Path,
41 project_key: &str,
42 project_root: &Path,
43 ) -> std::io::Result<Self> {
44 let dir = storage_dir.join("symbols").join(project_key);
45 let path = dir.join("symbols.lock");
46 let access = crate::root_cache::ArtifactAccess::for_root(project_root);
47 if !access.allows_write(project_key, &path) {
48 return Ok(Self { _guard: None });
49 }
50 fs::create_dir_all(&dir)?;
51 let _acquire_guard = SYMBOL_LOCK_ACQUIRE_MUTEX
52 .lock()
53 .map_err(|_| std::io::Error::other("symbol cache lock acquisition mutex poisoned"))?;
54 fs_lock::try_acquire(&path, Duration::from_secs(2))
55 .map(|guard| Self {
56 _guard: Some(guard),
57 })
58 .map_err(|error| match error {
59 fs_lock::AcquireError::Timeout => {
60 std::io::Error::other("timed out acquiring symbol cache lock")
61 }
62 fs_lock::AcquireError::Io(error) => error,
63 })
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct DiskSymbolCache {
69 pub(crate) entries: Vec<DiskSymbolEntry>,
70}
71
72#[derive(Debug, Clone)]
73pub(crate) struct DiskSymbolEntry {
74 pub(crate) relative_path: PathBuf,
75 pub(crate) mtime: SystemTime,
76 pub(crate) size: u64,
77 pub(crate) content_hash: blake3::Hash,
78 pub(crate) symbols: Vec<Symbol>,
79}
80
81impl DiskSymbolCache {
82 pub fn len(&self) -> usize {
83 self.entries.len()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.entries.is_empty()
88 }
89}
90
91pub(crate) fn cache_path(storage_dir: &Path, project_key: &str) -> PathBuf {
92 storage_dir
93 .join("symbols")
94 .join(project_key)
95 .join("symbols.bin")
96}
97
98#[cfg(test)]
99pub(crate) fn watch_cache_writes(path: PathBuf) {
100 *WATCHED_CACHE_WRITE
101 .get_or_init(|| Mutex::new(None))
102 .lock()
103 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((path, 0));
104}
105
106#[cfg(test)]
107pub(crate) fn watched_cache_write_count() -> usize {
108 WATCHED_CACHE_WRITE
109 .get_or_init(|| Mutex::new(None))
110 .lock()
111 .unwrap_or_else(std::sync::PoisonError::into_inner)
112 .as_ref()
113 .map_or(0, |(_, count)| *count)
114}
115
116#[cfg(test)]
117fn note_cache_write(path: &Path) {
118 let mut watched = WATCHED_CACHE_WRITE
119 .get_or_init(|| Mutex::new(None))
120 .lock()
121 .unwrap_or_else(std::sync::PoisonError::into_inner);
122 if let Some((watched_path, count)) = watched.as_mut() {
123 if watched_path == path {
124 *count += 1;
125 }
126 }
127}
128
129pub fn read_from_disk(storage_dir: &Path, project_key: &str) -> Option<DiskSymbolCache> {
130 let data_path = cache_path(storage_dir, project_key);
131 if !data_path.exists() {
132 return None;
133 }
134
135 match read_cache_file(&data_path) {
136 Ok(cache) => Some(cache),
137 Err(error) => {
138 slog_warn!(
139 "corrupt symbol cache at {}: {}, rebuilding",
140 data_path.display(),
141 error
142 );
143 None
144 }
145 }
146}
147
148pub fn write_to_disk(
149 cache: &SymbolCache,
150 storage_dir: &Path,
151 project_key: &str,
152) -> std::io::Result<()> {
153 let project_root = cache.project_root().ok_or_else(|| {
154 std::io::Error::other("symbol cache project root is not set; cannot persist relative paths")
155 })?;
156
157 let dir = storage_dir.join("symbols").join(project_key);
158 let data_path = dir.join("symbols.bin");
159 let access = crate::root_cache::ArtifactAccess::for_root(&project_root);
160 if !access.allows_write(project_key, &data_path) {
161 return Ok(());
162 }
163 #[cfg(test)]
164 note_cache_write(&data_path);
165 let _cache_lock = SymbolCacheLock::acquire(storage_dir, project_key, &project_root)?;
166 fs::create_dir_all(&dir)?;
167 let tmp_path = dir.join(format!(
168 "symbols.bin.tmp.{}.{}.{}",
169 std::process::id(),
170 SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .unwrap_or(Duration::ZERO)
173 .as_nanos(),
174 TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
175 ));
176 let write_result = write_cache_file(cache, &project_root, &tmp_path).and_then(|()| {
177 fs::rename(&tmp_path, &data_path)?;
178 if let Ok(dir_file) = File::open(&dir) {
179 let _ = dir_file.sync_all();
180 }
181 Ok(())
182 });
183
184 if write_result.is_err() {
185 let _ = fs::remove_file(&tmp_path);
186 }
187
188 write_result
189}
190
191fn read_cache_file(path: &Path) -> Result<DiskSymbolCache, String> {
192 let mut reader = BufReader::new(File::open(path).map_err(|error| error.to_string())?);
193
194 let mut magic = [0u8; 8];
195 reader
196 .read_exact(&mut magic)
197 .map_err(|error| format!("failed to read symbol cache magic: {error}"))?;
198 if &magic != MAGIC {
199 return Err("invalid symbol cache magic".to_string());
200 }
201
202 let format_version = read_u32(&mut reader)?;
203 if format_version != FORMAT_VERSION {
204 return Err(format!(
205 "unsupported symbol cache format version: {format_version} (expected {FORMAT_VERSION})"
206 ));
207 }
208
209 let schema_version = read_u32(&mut reader)?;
210 if schema_version != SCHEMA_VERSION {
211 return Err(format!(
212 "unsupported symbol cache schema version: {schema_version} (expected {SCHEMA_VERSION})"
213 ));
214 }
215
216 let root_len = read_u32(&mut reader)? as usize;
217 let entry_count = read_u32(&mut reader)? as usize;
218 if root_len > MAX_PATH_BYTES {
219 return Err(format!("project root path too large: {root_len} bytes"));
220 }
221 if entry_count > MAX_ENTRIES {
222 return Err(format!("too many symbol cache entries: {entry_count}"));
223 }
224
225 let _project_root = PathBuf::from(read_string_with_len(&mut reader, root_len)?);
226 let mut entries = Vec::with_capacity(entry_count);
227
228 for _ in 0..entry_count {
229 let path_len = read_u32(&mut reader)? as usize;
230 if path_len > MAX_PATH_BYTES {
231 return Err(format!("cached path too large: {path_len} bytes"));
232 }
233 let relative_path = validate_cached_relative_path(&PathBuf::from(read_string_with_len(
234 &mut reader,
235 path_len,
236 )?))
237 .ok_or_else(|| "cached symbol path escapes project root".to_string())?;
238 let mtime_secs = read_i64(&mut reader)?;
239 let mtime_nanos = read_u32(&mut reader)?;
240 let size = read_u64(&mut reader)?;
241 let mut hash_bytes = [0u8; 32];
242 reader
243 .read_exact(&mut hash_bytes)
244 .map_err(|error| format!("failed to read symbol content hash: {error}"))?;
245 let content_hash = blake3::Hash::from_bytes(hash_bytes);
246 let symbol_bytes_len = read_u32(&mut reader)? as usize;
247 if symbol_bytes_len > MAX_SYMBOL_BYTES {
248 return Err(format!(
249 "cached symbol payload too large: {symbol_bytes_len} bytes"
250 ));
251 }
252
253 let mut symbol_bytes = vec![0u8; symbol_bytes_len];
254 reader
255 .read_exact(&mut symbol_bytes)
256 .map_err(|error| format!("failed to read symbol payload: {error}"))?;
257 let symbols: Vec<Symbol> = serde_json::from_slice(&symbol_bytes)
258 .map_err(|error| format!("failed to decode cached symbols: {error}"))?;
259
260 entries.push(DiskSymbolEntry {
261 relative_path,
262 mtime: system_time_from_parts(mtime_secs, mtime_nanos)?,
263 size,
264 content_hash,
265 symbols,
266 });
267 }
268
269 Ok(DiskSymbolCache { entries })
270}
271
272fn write_cache_file(
273 cache: &SymbolCache,
274 project_root: &Path,
275 tmp_path: &Path,
276) -> std::io::Result<()> {
277 let mut writer = BufWriter::new(File::create(tmp_path)?);
278 let entries = cache
279 .disk_entries()
280 .into_iter()
281 .map(|(path, mtime, size, content_hash, symbols)| {
282 cache_relative_path(project_root, path)
283 .map(|relative_path| (relative_path, mtime, size, content_hash, symbols))
284 })
285 .collect::<Option<Vec<_>>>()
286 .ok_or_else(|| std::io::Error::other("refusing to cache path outside project root"))?;
287 let root = project_root.to_string_lossy();
288 let root_len = u32::try_from(root.len())
289 .map_err(|_| std::io::Error::other("project root too large to cache"))?;
290 let entry_count = u32::try_from(entries.len())
291 .map_err(|_| std::io::Error::other("too many symbol cache entries"))?;
292
293 writer.write_all(MAGIC)?;
294 write_u32(&mut writer, FORMAT_VERSION)?;
295 write_u32(&mut writer, SCHEMA_VERSION)?;
296 write_u32(&mut writer, root_len)?;
297 write_u32(&mut writer, entry_count)?;
298 writer.write_all(root.as_bytes())?;
299
300 for (relative_path, mtime, size, content_hash, symbols) in entries {
301 let path_bytes = relative_path.to_string_lossy();
302 let path_len = u32::try_from(path_bytes.len())
303 .map_err(|_| std::io::Error::other("cached path too large"))?;
304 let (secs, nanos) = system_time_parts(mtime);
305 let symbol_bytes = serde_json::to_vec(symbols).map_err(|error| {
306 std::io::Error::other(format!("symbol serialization failed: {error}"))
307 })?;
308 let symbol_len = u32::try_from(symbol_bytes.len())
309 .map_err(|_| std::io::Error::other("cached symbol payload too large"))?;
310
311 write_u32(&mut writer, path_len)?;
312 writer.write_all(path_bytes.as_bytes())?;
313 write_i64(&mut writer, secs)?;
314 write_u32(&mut writer, nanos)?;
315 write_u64(&mut writer, size)?;
316 writer.write_all(content_hash.as_bytes())?;
317 write_u32(&mut writer, symbol_len)?;
318 writer.write_all(&symbol_bytes)?;
319 }
320
321 writer.flush()?;
322 writer.get_ref().sync_all()?;
323 Ok(())
324}
325
326fn system_time_parts(time: SystemTime) -> (i64, u32) {
327 match time.duration_since(UNIX_EPOCH) {
328 Ok(duration) => (
329 i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
330 duration.subsec_nanos(),
331 ),
332 Err(error) => {
333 let duration = error.duration();
334 let nanos = duration.subsec_nanos();
335 if nanos == 0 {
336 (-(duration.as_secs() as i64), 0)
337 } else {
338 (-(duration.as_secs() as i64) - 1, 1_000_000_000 - nanos)
339 }
340 }
341 }
342}
343
344fn system_time_from_parts(secs: i64, nanos: u32) -> Result<SystemTime, String> {
345 if nanos >= 1_000_000_000 {
346 return Err(format!(
347 "invalid symbol cache mtime nanos: {nanos} >= 1_000_000_000"
348 ));
349 }
350
351 if secs >= 0 {
352 let duration = Duration::new(secs as u64, nanos);
353 UNIX_EPOCH
354 .checked_add(duration)
355 .ok_or_else(|| format!("symbol cache mtime overflows SystemTime: {secs}.{nanos}"))
356 } else {
357 let whole = Duration::new(secs.unsigned_abs(), 0);
358 let base = UNIX_EPOCH.checked_sub(whole).ok_or_else(|| {
359 format!("symbol cache negative mtime overflows SystemTime: {secs}.{nanos}")
360 })?;
361 base.checked_add(Duration::new(0, nanos)).ok_or_else(|| {
362 format!("symbol cache negative mtime overflows SystemTime: {secs}.{nanos}")
363 })
364 }
365}
366
367fn read_string_with_len<R: Read>(reader: &mut R, len: usize) -> Result<String, String> {
368 let mut bytes = vec![0u8; len];
369 reader
370 .read_exact(&mut bytes)
371 .map_err(|error| format!("failed to read string: {error}"))?;
372 String::from_utf8(bytes).map_err(|error| format!("invalid utf-8 string: {error}"))
373}
374
375fn read_u32<R: Read>(reader: &mut R) -> Result<u32, String> {
376 let mut bytes = [0u8; 4];
377 reader
378 .read_exact(&mut bytes)
379 .map_err(|error| format!("failed to read u32: {error}"))?;
380 Ok(u32::from_le_bytes(bytes))
381}
382
383fn read_i64<R: Read>(reader: &mut R) -> Result<i64, String> {
384 let mut bytes = [0u8; 8];
385 reader
386 .read_exact(&mut bytes)
387 .map_err(|error| format!("failed to read i64: {error}"))?;
388 Ok(i64::from_le_bytes(bytes))
389}
390
391fn read_u64<R: Read>(reader: &mut R) -> Result<u64, String> {
392 let mut bytes = [0u8; 8];
393 reader
394 .read_exact(&mut bytes)
395 .map_err(|error| format!("failed to read u64: {error}"))?;
396 Ok(u64::from_le_bytes(bytes))
397}
398
399fn write_u32<W: Write>(writer: &mut W, value: u32) -> std::io::Result<()> {
400 writer.write_all(&value.to_le_bytes())
401}
402
403fn write_i64<W: Write>(writer: &mut W, value: i64) -> std::io::Result<()> {
404 writer.write_all(&value.to_le_bytes())
405}
406
407fn write_u64<W: Write>(writer: &mut W, value: u64) -> std::io::Result<()> {
408 writer.write_all(&value.to_le_bytes())
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::symbols::{Range, SymbolKind};
415
416 fn test_symbol(name: &str) -> Symbol {
417 Symbol {
418 name: name.to_string(),
419 kind: SymbolKind::Function,
420 range: Range {
421 start_line: 0,
422 start_col: 0,
423 end_line: 0,
424 end_col: 1,
425 },
426 signature: None,
427 scope_chain: Vec::new(),
428 exported: false,
429 parent: None,
430 }
431 }
432
433 fn test_cache(project: &Path, file_name: &str) -> SymbolCache {
434 let file = project.join(file_name);
435 fs::write(&file, format!("fn {file_name}() {{}}\n")).expect("write file");
436 let metadata = fs::metadata(&file).expect("metadata");
437 let content_hash = blake3::hash(&fs::read(&file).expect("read file"));
438 let mut cache = SymbolCache::new();
439 cache.set_project_root(project.to_path_buf());
440 cache.insert(
441 file,
442 metadata.modified().expect("mtime"),
443 metadata.len(),
444 content_hash,
445 vec![test_symbol(file_name)],
446 );
447 cache
448 }
449
450 #[test]
451 fn borrow_only_root_skips_symbol_lock_and_persist() {
452 let project = tempfile::tempdir().expect("project");
453 let storage = tempfile::tempdir().expect("storage");
454 let project_key = "shared-artifact-key".to_string();
455 crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
456 let cache = test_cache(project.path(), "lib.rs");
457
458 let _lock = SymbolCacheLock::acquire(storage.path(), &project_key, project.path())
459 .expect("borrow-only lock downgrade");
460 let cache_dir = storage.path().join("symbols").join(&project_key);
461 assert!(!cache_dir.join("symbols.lock").exists());
462
463 write_to_disk(&cache, storage.path(), &project_key).expect("borrow-only persist downgrade");
464
465 assert!(!cache_dir.join("symbols.bin").exists());
466 assert!(!cache_dir.exists());
467 }
468
469 #[test]
470 fn concurrent_symbol_cache_writes_do_not_share_temp_file() {
471 let dir = tempfile::tempdir().expect("create temp dir");
472 let project = dir.path().join("project");
473 fs::create_dir_all(&project).expect("create project");
474 let storage = dir.path().join("storage");
475
476 let cache_a = test_cache(&project, "a");
477 let cache_b = test_cache(&project, "b");
478 let storage_a = storage.clone();
479 let writer_a = std::thread::spawn(move || {
480 write_to_disk(&cache_a, &storage_a, "unit-project").expect("write a");
481 });
482 let storage_b = storage.clone();
483 let writer_b = std::thread::spawn(move || {
484 write_to_disk(&cache_b, &storage_b, "unit-project").expect("write b");
485 });
486
487 writer_a.join().expect("writer a");
488 writer_b.join().expect("writer b");
489
490 let loaded = read_from_disk(&storage, "unit-project").expect("load symbol cache");
491 assert_eq!(loaded.len(), 1);
492 assert!(fs::read_dir(storage.join("symbols").join("unit-project"))
493 .expect("read symbol cache dir")
494 .all(|entry| !entry
495 .expect("cache entry")
496 .file_name()
497 .to_string_lossy()
498 .contains(".tmp.")));
499 }
500
501 #[test]
502 fn symbol_cache_rejects_mismatched_schema_version() {
503 let storage = tempfile::tempdir().expect("create storage dir");
504 let path = cache_path(storage.path(), "schema-project");
505 fs::create_dir_all(path.parent().expect("cache parent")).expect("create cache dir");
506
507 let mut bytes = Vec::new();
508 bytes.extend_from_slice(MAGIC);
509 bytes.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
510 bytes.extend_from_slice(&SCHEMA_VERSION.wrapping_add(1).to_le_bytes());
511 bytes.extend_from_slice(&0u32.to_le_bytes());
512 bytes.extend_from_slice(&0u32.to_le_bytes());
513 fs::write(&path, bytes).expect("write wrong-schema cache");
514
515 assert!(read_from_disk(storage.path(), "schema-project").is_none());
516 }
517
518 #[test]
519 fn symbol_cache_rejects_paths_outside_project_root_on_write() {
520 let dir = tempfile::tempdir().expect("create temp dir");
521 let project = dir.path().join("project");
522 fs::create_dir_all(&project).expect("create project");
523 let outside = dir.path().join("outside.rs");
524 fs::write(&outside, "fn outside() {}\n").expect("write outside");
525 let metadata = fs::metadata(&outside).expect("metadata");
526
527 let mut cache = SymbolCache::new();
528 cache.set_project_root(project);
529 cache.insert(
530 outside.clone(),
531 metadata.modified().expect("mtime"),
532 metadata.len(),
533 blake3::hash(&fs::read(&outside).expect("read outside")),
534 vec![test_symbol("outside")],
535 );
536
537 let error = write_to_disk(&cache, dir.path(), "escape-project").expect_err("reject escape");
538 assert!(error.to_string().contains("outside project root"));
539 }
540}