Skip to main content

aft/
symbol_cache_disk.rs

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
19/// Version of the symbol extraction schema stored in the disk cache.
20///
21/// Bump this whenever symbol-extraction logic changes: tree-sitter grammar
22/// upgrades, query updates, extractor behavior, or symbol shape changes. A
23/// mismatch rejects persisted symbols so they are regenerated on next access.
24pub 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}