Skip to main content

provenant/cache/
incremental.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::BTreeMap;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde::{Deserialize, Serialize};
11
12use super::io::write_bytes_atomically;
13use super::locking::with_exclusive_cache_lock;
14use crate::models::{FileInfo, Sha256Digest};
15use crate::utils::hash::calculate_sha256;
16
17const INCREMENTAL_MANIFEST_VERSION: u32 = 5;
18const MANIFEST_FILE_NAME: &str = "manifest.postcard";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct FileStateFingerprint {
22    pub size: u64,
23    pub modified_seconds: u64,
24    pub modified_nanos: u32,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct IncrementalManifestEntry {
29    pub state: FileStateFingerprint,
30    pub content_sha256: Sha256Digest,
31    pub file_info: FileInfo,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct IncrementalManifest {
36    pub version: u32,
37    pub options_fingerprint: String,
38    pub entries: BTreeMap<String, IncrementalManifestEntry>,
39}
40
41impl IncrementalManifest {
42    pub fn new(
43        options_fingerprint: String,
44        entries: BTreeMap<String, IncrementalManifestEntry>,
45    ) -> Self {
46        Self {
47            version: INCREMENTAL_MANIFEST_VERSION,
48            options_fingerprint,
49            entries,
50        }
51    }
52
53    pub fn entry(&self, relative_path: &str) -> Option<&IncrementalManifestEntry> {
54        self.entries.get(relative_path)
55    }
56
57    pub fn is_compatible_with(&self, options_fingerprint: &str) -> bool {
58        self.version == INCREMENTAL_MANIFEST_VERSION
59            && self.options_fingerprint == options_fingerprint
60    }
61}
62
63pub fn incremental_manifest_path(cache_root: &Path, manifest_key: &str) -> PathBuf {
64    cache_root
65        .join("incremental")
66        .join(manifest_key)
67        .join(MANIFEST_FILE_NAME)
68}
69
70pub fn metadata_fingerprint(metadata: &fs::Metadata) -> Option<FileStateFingerprint> {
71    let modified = metadata.modified().ok()?;
72    let duration = modified.duration_since(UNIX_EPOCH).ok()?;
73
74    Some(FileStateFingerprint {
75        size: metadata.len(),
76        modified_seconds: duration.as_secs(),
77        modified_nanos: duration.subsec_nanos(),
78    })
79}
80
81/// Decides whether a cached manifest entry can be reused for `path`.
82///
83/// Both modes first require the size + nanosecond-mtime fingerprint to match.
84///
85/// When `trust_mtime` is `false` (the default, paranoid mode) the file is
86/// re-read and SHA-256 hashed, so a content change that preserves both size and
87/// mtime is still detected. When `trust_mtime` is `true` the fingerprint match
88/// alone is accepted and the read + hash are skipped, trading the rare
89/// same-tick, same-size silent edit for warm-rescan speed.
90pub fn manifest_entry_matches_path(
91    entry: &IncrementalManifestEntry,
92    path: &Path,
93    metadata: &fs::Metadata,
94    trust_mtime: bool,
95) -> io::Result<bool> {
96    if !metadata_fingerprint(metadata).is_some_and(|fingerprint| fingerprint == entry.state) {
97        return Ok(false);
98    }
99
100    if trust_mtime {
101        return Ok(true);
102    }
103
104    let bytes = fs::read(path)?;
105    Ok(calculate_sha256(&bytes) == entry.content_sha256)
106}
107
108pub fn load_incremental_manifest(
109    manifest_path: &Path,
110    options_fingerprint: &str,
111) -> io::Result<Option<IncrementalManifest>> {
112    let bytes = match fs::read(manifest_path) {
113        Ok(bytes) => bytes,
114        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
115        Err(err) => return Err(err),
116    };
117
118    let manifest: IncrementalManifest = match postcard::from_bytes(&bytes) {
119        Ok(manifest) => manifest,
120        Err(_) => return Ok(None),
121    };
122
123    if !manifest.is_compatible_with(options_fingerprint) {
124        return Ok(None);
125    }
126
127    Ok(Some(manifest))
128}
129
130pub fn write_incremental_manifest(
131    cache_root: &Path,
132    manifest_path: &Path,
133    manifest: &IncrementalManifest,
134) -> io::Result<()> {
135    let bytes = postcard::to_allocvec(manifest)
136        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
137
138    with_exclusive_cache_lock(cache_root, || write_bytes_atomically(manifest_path, &bytes))
139}
140
141#[cfg(test)]
142mod tests {
143    use tempfile::TempDir;
144
145    use super::*;
146    use crate::models::{DiagnosticSeverity, FileInfo, FileType, ScanDiagnostic};
147
148    fn sample_manifest(options_fingerprint: &str) -> IncrementalManifest {
149        let mut entries = BTreeMap::new();
150        entries.insert(
151            "src/main.rs".to_string(),
152            IncrementalManifestEntry {
153                state: FileStateFingerprint {
154                    size: 12,
155                    modified_seconds: 10,
156                    modified_nanos: 20,
157                },
158                content_sha256: Sha256Digest::from_hex(
159                    "f2ca1bb6c7e907d06dafe4687e579fce9f2b2c8a179a4e7c1f6c5052d4f7d070",
160                )
161                .unwrap(),
162                file_info: FileInfo::new(
163                    "main.rs".to_string(),
164                    "main".to_string(),
165                    ".rs".to_string(),
166                    "/tmp/project/src/main.rs".to_string(),
167                    FileType::File,
168                    None,
169                    None,
170                    12,
171                    None,
172                    None,
173                    None,
174                    None,
175                    None,
176                    Vec::new(),
177                    None,
178                    Vec::new(),
179                    Vec::new(),
180                    Vec::new(),
181                    Vec::new(),
182                    Vec::new(),
183                    Vec::new(),
184                    Vec::new(),
185                    Vec::new(),
186                    Vec::new(),
187                ),
188            },
189        );
190
191        IncrementalManifest::new(options_fingerprint.to_string(), entries)
192    }
193
194    #[test]
195    fn test_load_incremental_manifest_returns_none_for_incompatible_options() {
196        let temp_dir = TempDir::new().expect("create temp dir");
197        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
198        let manifest = sample_manifest("options-v1");
199
200        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
201            .expect("write manifest");
202
203        let loaded =
204            load_incremental_manifest(&manifest_path, "options-v2").expect("load manifest");
205
206        assert!(loaded.is_none());
207    }
208
209    #[test]
210    fn test_load_incremental_manifest_returns_none_for_older_manifest_version() {
211        let temp_dir = TempDir::new().expect("create temp dir");
212        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
213        let mut manifest = sample_manifest("options-v1");
214        manifest.version = 1;
215
216        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
217            .expect("write manifest");
218
219        let loaded =
220            load_incremental_manifest(&manifest_path, "options-v1").expect("load manifest");
221
222        assert!(loaded.is_none());
223    }
224
225    #[test]
226    fn test_write_and_load_incremental_manifest_round_trip() {
227        let temp_dir = TempDir::new().expect("create temp dir");
228        let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
229        let manifest = sample_manifest("options-v1");
230
231        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
232            .expect("write manifest");
233
234        let loaded = load_incremental_manifest(&manifest_path, "options-v1")
235            .expect("load manifest")
236            .expect("expected manifest");
237
238        assert_eq!(loaded.entries.len(), 1);
239        assert!(loaded.entry("src/main.rs").is_some());
240    }
241
242    #[test]
243    fn test_incremental_manifest_preserves_scan_diagnostic_severity() {
244        let temp_dir = TempDir::new().expect("create temp dir");
245        let manifest_path = incremental_manifest_path(temp_dir.path(), "diag");
246        let mut manifest = sample_manifest("options-v1");
247        let entry = manifest
248            .entries
249            .get_mut("src/main.rs")
250            .expect("manifest entry");
251        entry.file_info.scan_diagnostics =
252            vec![ScanDiagnostic::warning("custom recoverable warning")];
253
254        write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
255            .expect("write manifest");
256
257        let loaded = load_incremental_manifest(&manifest_path, "options-v1")
258            .expect("load manifest")
259            .expect("expected manifest");
260
261        let loaded_entry = loaded.entry("src/main.rs").expect("loaded entry");
262        assert_eq!(loaded_entry.file_info.scan_diagnostics.len(), 1);
263        assert_eq!(
264            loaded_entry.file_info.scan_diagnostics[0].severity,
265            DiagnosticSeverity::Warning
266        );
267    }
268
269    #[test]
270    fn test_manifest_entry_matches_path_detects_content_changes() {
271        let temp_dir = TempDir::new().expect("create temp dir");
272        let file_path = temp_dir.path().join("src/main.rs");
273        fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
274        fs::write(&file_path, "fn main() {}\n").expect("write file");
275        let metadata = fs::metadata(&file_path).expect("metadata");
276
277        let entry = IncrementalManifestEntry {
278            state: metadata_fingerprint(&metadata).expect("fingerprint"),
279            content_sha256: Sha256Digest::from_hex("not-the-real-hash")
280                .unwrap_or(Sha256Digest::EMPTY),
281            file_info: FileInfo::new(
282                "main.rs".to_string(),
283                "main".to_string(),
284                ".rs".to_string(),
285                file_path.to_string_lossy().to_string(),
286                FileType::File,
287                None,
288                None,
289                metadata.len(),
290                None,
291                None,
292                None,
293                None,
294                None,
295                Vec::new(),
296                None,
297                Vec::new(),
298                Vec::new(),
299                Vec::new(),
300                Vec::new(),
301                Vec::new(),
302                Vec::new(),
303                Vec::new(),
304                Vec::new(),
305                Vec::new(),
306            ),
307        };
308
309        assert!(
310            !manifest_entry_matches_path(&entry, &file_path, &metadata, false)
311                .expect("compare path")
312        );
313    }
314
315    /// Builds a manifest entry whose recorded SHA-256 deliberately does NOT
316    /// match the file's real contents, so any code path that re-hashes the file
317    /// returns `false` while a path that trusts the fingerprint returns `true`.
318    fn entry_with_wrong_hash(
319        file_path: &Path,
320        metadata: &fs::Metadata,
321    ) -> IncrementalManifestEntry {
322        IncrementalManifestEntry {
323            state: metadata_fingerprint(metadata).expect("fingerprint"),
324            content_sha256: Sha256Digest::EMPTY,
325            file_info: FileInfo::new(
326                "main.rs".to_string(),
327                "main".to_string(),
328                ".rs".to_string(),
329                file_path.to_string_lossy().to_string(),
330                FileType::File,
331                None,
332                None,
333                metadata.len(),
334                None,
335                None,
336                None,
337                None,
338                None,
339                Vec::new(),
340                None,
341                Vec::new(),
342                Vec::new(),
343                Vec::new(),
344                Vec::new(),
345                Vec::new(),
346                Vec::new(),
347                Vec::new(),
348                Vec::new(),
349                Vec::new(),
350            ),
351        }
352    }
353
354    #[test]
355    fn test_trust_mtime_accepts_fingerprint_match_without_rehashing() {
356        let temp_dir = TempDir::new().expect("create temp dir");
357        let file_path = temp_dir.path().join("src/main.rs");
358        fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
359        fs::write(&file_path, "fn main() {}\n").expect("write file");
360        let metadata = fs::metadata(&file_path).expect("metadata");
361
362        // The recorded hash is wrong, so the only way this returns `true` is by
363        // trusting the fingerprint and skipping the read + hash.
364        let entry = entry_with_wrong_hash(&file_path, &metadata);
365
366        assert!(
367            manifest_entry_matches_path(&entry, &file_path, &metadata, true).expect("compare path"),
368            "trust-mtime mode must accept a size+mtime match without re-hashing"
369        );
370    }
371
372    #[test]
373    fn test_default_mode_rehashes_on_fingerprint_match() {
374        let temp_dir = TempDir::new().expect("create temp dir");
375        let file_path = temp_dir.path().join("src/main.rs");
376        fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
377        fs::write(&file_path, "fn main() {}\n").expect("write file");
378        let metadata = fs::metadata(&file_path).expect("metadata");
379
380        // Same wrong-hash entry: default (paranoid) mode must re-hash and reject.
381        let entry = entry_with_wrong_hash(&file_path, &metadata);
382
383        assert!(
384            !manifest_entry_matches_path(&entry, &file_path, &metadata, false)
385                .expect("compare path"),
386            "default mode must re-hash and reject a fingerprint match whose content differs"
387        );
388    }
389
390    #[test]
391    fn test_trust_mtime_still_detects_changed_fingerprint() {
392        let temp_dir = TempDir::new().expect("create temp dir");
393        let file_path = temp_dir.path().join("src/main.rs");
394        fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
395        fs::write(&file_path, "fn main() {}\n").expect("write file");
396        let metadata = fs::metadata(&file_path).expect("metadata");
397
398        // Record a fingerprint for a different size so the size check alone fails.
399        let mut state = metadata_fingerprint(&metadata).expect("fingerprint");
400        state.size += 1;
401        let mut entry = entry_with_wrong_hash(&file_path, &metadata);
402        entry.state = state;
403
404        assert!(
405            !manifest_entry_matches_path(&entry, &file_path, &metadata, true)
406                .expect("compare path"),
407            "trust-mtime mode must still treat a changed size/mtime fingerprint as changed"
408        );
409    }
410}