Skip to main content

mbx_cache_core/
local.rs

1use crate::{CacheDigest, RemoteActionResult, canonical_json};
2use eyre::{Result, bail};
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7/// A validated, content-addressed store on the local filesystem.
8#[derive(Debug, Clone)]
9pub struct LocalCas {
10    root: PathBuf,
11}
12
13/// A local index from action digests to their referenced cache objects.
14#[derive(Debug, Clone)]
15pub struct LocalActionCache {
16    root: PathBuf,
17    cas: LocalCas,
18}
19
20impl LocalCas {
21    /// Create a local content-addressed store beneath `root`.
22    pub fn new(root: impl Into<PathBuf>) -> Self {
23        Self { root: root.into() }
24    }
25
26    /// Return the root shared by this store and its action-result index.
27    pub fn root(&self) -> &Path {
28        &self.root
29    }
30
31    /// Resolve the storage path for a validated digest.
32    pub fn path_for(&self, digest: &CacheDigest) -> Result<PathBuf> {
33        digest.validate()?;
34        Ok(self
35            .root
36            .join("cas/v1")
37            .join(&digest.algorithm)
38            .join(&digest.hash[..2])
39            .join(format!("{}-{}", digest.hash, digest.size)))
40    }
41
42    /// Find and verify a stored object.
43    pub fn find(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
44        let path = self.path_for(digest)?;
45        if !path.exists() {
46            return Ok(None);
47        }
48        if !digest.matches_file(&path)? {
49            bail!(
50                "local CAS blob failed digest verification: {}",
51                path.display()
52            );
53        }
54        Ok(Some(path))
55    }
56
57    /// Atomically store bytes after verifying their declared digest.
58    pub fn store_bytes(&self, digest: &CacheDigest, bytes: &[u8]) -> Result<PathBuf> {
59        if !digest.matches_bytes(bytes)? {
60            bail!("bytes do not match the declared CAS digest");
61        }
62        self.store_with(digest, |temporary| {
63            temporary.write_all(bytes)?;
64            Ok(())
65        })
66    }
67
68    /// Atomically store a file after verifying its declared digest.
69    pub fn store_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
70        self.store_file_inner(digest, source, true)
71    }
72
73    /// Store a file whose digest was already verified by this crate.
74    pub(crate) fn store_verified_file(
75        &self,
76        digest: &CacheDigest,
77        source: &Path,
78    ) -> Result<PathBuf> {
79        self.store_file_inner(digest, source, false)
80    }
81
82    /// Move an already-verified temporary file into the CAS when possible.
83    ///
84    /// Remote downloads are staged beneath the cache root, so the usual path
85    /// is an atomic same-filesystem rename. If a caller supplies a path on a
86    /// different filesystem, fall back to the copy-based store operation.
87    pub fn adopt_verified_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
88        if fs::metadata(source)?.len() != digest.size {
89            bail!("staged blob size does not match the declared CAS digest");
90        }
91        let destination = self.path_for(digest)?;
92        match self.find(digest) {
93            Ok(Some(existing)) => return Ok(existing),
94            // Preserve the established repair path for a poisoned destination;
95            // replacing it portably needs the copy-based temporary file flow.
96            Err(_) => return self.store_file_inner(digest, source, false),
97            Ok(None) => {}
98        }
99        fs::create_dir_all(destination.parent().expect("CAS path has a parent"))?;
100        make_owner_writable(source)?;
101        match fs::rename(source, &destination) {
102            Ok(()) => Ok(destination),
103            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => self
104                .find(digest)?
105                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
106            Err(_) => self.store_file_inner(digest, source, false),
107        }
108    }
109
110    fn store_file_inner(
111        &self,
112        digest: &CacheDigest,
113        source: &Path,
114        verify: bool,
115    ) -> Result<PathBuf> {
116        let destination = self.path_for(digest)?;
117        // A blob that fails verification cannot be restored from, and nothing
118        // else repairs it: readers treat it as a miss, so without republishing
119        // over it the digest stays poisoned until eviction happens to reclaim
120        // it. `LocalActionCache::store` already recovers this way one layer up.
121        let replace_invalid = match self.find(digest) {
122            Ok(Some(existing)) => return Ok(existing),
123            Ok(None) => false,
124            Err(_) => true,
125        };
126        // On Unix the source is hashed before it is copied, and the copy is
127        // only trusted if the source still looks the same afterwards. A
128        // compiler output was written moments ago, so its pages are hot; the
129        // reflinked copy is a fresh inode whose pages are not, and hashing it
130        // instead read every published byte back from disk. A source
131        // rewritten between the hash and the copy shows in its change time,
132        // which nothing in user space can set back, and is refused, so what
133        // lands under the digest is what was hashed. Windows reports no
134        // change time and lets a writer restore the modification time, so
135        // there the copy itself is hashed, as it always was.
136        let hash_source = verify && cfg!(unix);
137        let before = hash_source.then(|| fs::metadata(source)).transpose()?;
138        if hash_source && !digest.matches_file(source)? {
139            bail!("staged blob does not match the declared CAS digest");
140        }
141        let parent = destination.parent().expect("CAS path has a parent");
142        fs::create_dir_all(parent)?;
143        let staging = tempfile::tempdir_in(parent)?;
144        let temporary = staging.path().join("blob");
145        reflink_copy::reflink_or_copy(source, &temporary)?;
146        let temporary = tempfile::TempPath::try_from_path(temporary)?;
147        make_owner_writable(&temporary)?;
148        if let Some(before) = before
149            && !same_identity(&before, &fs::metadata(source)?)
150        {
151            bail!("source changed while it was being stored in the CAS");
152        }
153        if verify && !hash_source && !digest.matches_file(&temporary)? {
154            bail!("staged blob does not match the declared CAS digest");
155        }
156        // Not fsynced: every read verifies the digest, so a blob torn by a
157        // crash is detected and treated as absent rather than trusted.
158        if fs::metadata(&temporary)?.len() != digest.size {
159            bail!("staged blob size does not match the declared CAS digest");
160        }
161        if replace_invalid {
162            temporary
163                .persist(&destination)
164                .map_err(|error| error.error)?;
165            return Ok(destination);
166        }
167        match temporary.persist_noclobber(&destination) {
168            Ok(()) => Ok(destination),
169            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
170                .find(digest)?
171                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
172            Err(error) => Err(error.error.into()),
173        }
174    }
175
176    fn store_with(
177        &self,
178        digest: &CacheDigest,
179        write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
180    ) -> Result<PathBuf> {
181        let destination = self.path_for(digest)?;
182        // See `store_file_inner` for why an invalid blob is replaced.
183        let replace_invalid = match self.find(digest) {
184            Ok(Some(existing)) => return Ok(existing),
185            Ok(None) => false,
186            Err(_) => true,
187        };
188        let parent = destination.parent().expect("CAS path has a parent");
189        fs::create_dir_all(parent)?;
190        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
191        write(&mut temporary)?;
192        temporary.flush()?;
193        if !digest.matches_file(temporary.path())? {
194            bail!("staged blob does not match the declared CAS digest");
195        }
196        if replace_invalid {
197            temporary
198                .persist(&destination)
199                .map_err(|error| error.error)?;
200            return Ok(destination);
201        }
202        match temporary.persist_noclobber(&destination) {
203            Ok(_) => Ok(destination),
204            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
205                .find(digest)?
206                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
207            Err(error) => Err(error.error.into()),
208        }
209    }
210}
211
212/// Whether two views of a file could describe the same unmodified bytes.
213///
214/// Length and modification time are what a rewrite through any path changes,
215/// and the change time is what a writer cannot set back. Only Unix reports
216/// one, which is why the store trusts this check only there.
217fn same_identity(before: &fs::Metadata, after: &fs::Metadata) -> bool {
218    if before.len() != after.len() || before.modified().ok() != after.modified().ok() {
219        return false;
220    }
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::MetadataExt as _;
224        if before.dev() != after.dev()
225            || before.ino() != after.ino()
226            || before.ctime() != after.ctime()
227            || before.ctime_nsec() != after.ctime_nsec()
228        {
229            return false;
230        }
231    }
232    true
233}
234
235#[cfg(unix)]
236fn make_owner_writable(path: &Path) -> Result<()> {
237    use std::os::unix::fs::PermissionsExt as _;
238    let mut permissions = fs::metadata(path)?.permissions();
239    permissions.set_mode(permissions.mode() | 0o200);
240    fs::set_permissions(path, permissions)?;
241    Ok(())
242}
243
244// The lint warns that this makes a Unix file world-writable. Windows has no
245// such mode: it clears only the read-only attribute.
246#[cfg(windows)]
247#[allow(clippy::permissions_set_readonly_false)]
248fn make_owner_writable(path: &Path) -> Result<()> {
249    let mut permissions = fs::metadata(path)?.permissions();
250    permissions.set_readonly(false);
251    fs::set_permissions(path, permissions)?;
252    Ok(())
253}
254
255impl LocalActionCache {
256    /// Create an action-result index beneath `root`.
257    pub fn new(root: impl Into<PathBuf>) -> Self {
258        let root = root.into();
259        Self {
260            cas: LocalCas::new(root.clone()),
261            root,
262        }
263    }
264
265    /// Resolve the storage path for an action digest.
266    pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
267        action.validate()?;
268        if action.algorithm != "blake3" {
269            bail!("local action keys must use blake3");
270        }
271        Ok(self
272            .root
273            .join("action-results/v1")
274            .join(&action.algorithm)
275            .join(&action.hash[..2])
276            .join(format!("{}-{}.json", action.hash, action.size)))
277    }
278
279    /// Find and strictly validate a canonical action result.
280    pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
281        let path = self.path_for(action)?;
282        if !path.exists() {
283            return Ok(None);
284        }
285        let bytes = fs::read(&path)?;
286        let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
287        if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
288            bail!("local action result is invalid: {}", path.display());
289        }
290        Ok(Some(result))
291    }
292
293    /// Whether the objects `result` names that this index can see are all
294    /// present and verified: its metadata, its output directory and the files
295    /// that directory lists.
296    fn is_intact(&self, result: &RemoteActionResult) -> bool {
297        let present = |digest: &CacheDigest| matches!(self.cas.find(digest), Ok(Some(_)));
298        present(&result.action)
299            && result.metadata.as_ref().is_none_or(present)
300            && result.output_root.as_ref().is_none_or(|root| {
301                self.directory_is_intact(root, &mut std::collections::BTreeSet::new())
302            })
303    }
304
305    /// Whether `digest` names a directory object whose files and nested
306    /// directories are all present and verified.
307    fn directory_is_intact(
308        &self,
309        digest: &CacheDigest,
310        seen: &mut std::collections::BTreeSet<CacheDigest>,
311    ) -> bool {
312        if !seen.insert(digest.clone()) {
313            return true;
314        }
315        let Ok(Some(path)) = self.cas.find(digest) else {
316            return false;
317        };
318        let Ok(bytes) = fs::read(path) else {
319            return false;
320        };
321        let Ok(directory) = serde_json::from_slice::<crate::CacheDirectory>(&bytes) else {
322            return false;
323        };
324        directory
325            .files
326            .iter()
327            .all(|file| matches!(self.cas.find(&file.digest), Ok(Some(_))))
328            && directory
329                .directories
330                .iter()
331                .all(|child| self.directory_is_intact(&child.digest, seen))
332    }
333
334    /// Atomically publish an action result after validating all referenced objects.
335    pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
336        if result.version != 1 {
337            bail!("unsupported local action result version");
338        }
339        for digest in [
340            Some(&result.action),
341            result.metadata.as_ref(),
342            result.output_root.as_ref(),
343        ]
344        .into_iter()
345        .flatten()
346        {
347            if self.cas.find(digest)?.is_none() {
348                bail!("cannot publish an action result with a missing blob");
349            }
350        }
351        let destination = self.path_for(&result.action)?;
352        let replace_invalid = match self.find(&result.action) {
353            Ok(Some(existing)) => {
354                if existing == *result {
355                    return Ok(destination);
356                }
357                // A different record is kept so a key never changes under a
358                // reader, unless its objects are gone or torn: then it can
359                // never be restored, and this result is the repair.
360                if self.is_intact(&existing) {
361                    bail!("local action key already has a different result");
362                }
363                true
364            }
365            Ok(None) => false,
366            Err(_) => true,
367        };
368        let parent = destination
369            .parent()
370            .expect("action-result path has a parent");
371        fs::create_dir_all(parent)?;
372        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
373        temporary.write_all(&canonical_json(result)?)?;
374        temporary.flush()?;
375        if replace_invalid {
376            temporary
377                .persist(&destination)
378                .map_err(|error| error.error)?;
379            return Ok(destination);
380        }
381        match temporary.persist_noclobber(&destination) {
382            Ok(_) => Ok(destination),
383            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
384                .find(&result.action)?
385                .filter(|existing| existing == result)
386                .map(|_| destination)
387                .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
388            Err(error) => Err(error.error.into()),
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn stores_and_validates_blobs_atomically() {
399        let directory = tempfile::tempdir().unwrap();
400        let cas = LocalCas::new(directory.path());
401        let digest = CacheDigest::blake3(b"cached object");
402
403        let path = cas.store_bytes(&digest, b"cached object").unwrap();
404        assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
405        assert_eq!(fs::read(&path).unwrap(), b"cached object");
406        assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
407        assert!(cas.store_bytes(&digest, b"other object").is_err());
408    }
409
410    #[test]
411    fn stored_files_are_independent_from_the_source() {
412        let directory = tempfile::tempdir().unwrap();
413        let cas = LocalCas::new(directory.path().join("cache"));
414        let source = directory.path().join("source");
415        fs::write(&source, b"cached object").unwrap();
416        let digest = CacheDigest::blake3(b"cached object");
417
418        let stored = cas.store_file(&digest, &source).unwrap();
419        fs::write(source, b"other object!").unwrap();
420
421        assert_eq!(fs::read(stored).unwrap(), b"cached object");
422        assert!(cas.find(&digest).unwrap().is_some());
423    }
424
425    #[test]
426    fn adopts_verified_files_without_leaving_the_staging_copy() {
427        let directory = tempfile::tempdir().unwrap();
428        let cas = LocalCas::new(directory.path().join("cache"));
429        let staging = directory.path().join("remote");
430        fs::create_dir(&staging).unwrap();
431        let source = staging.join("blob");
432        fs::write(&source, b"cached object").unwrap();
433        let digest = CacheDigest::blake3(b"cached object");
434
435        let stored = cas.adopt_verified_file(&digest, &source).unwrap();
436
437        assert!(!source.exists());
438        assert_eq!(fs::read(&stored).unwrap(), b"cached object");
439        assert_eq!(cas.find(&digest).unwrap(), Some(stored));
440    }
441
442    #[test]
443    fn rejects_files_with_the_wrong_digest() {
444        let directory = tempfile::tempdir().unwrap();
445        let cas = LocalCas::new(directory.path().join("cache"));
446        let source = directory.path().join("source");
447        fs::write(&source, b"other object").unwrap();
448        let digest = CacheDigest::blake3(b"cached object");
449
450        assert!(cas.store_file(&digest, &source).is_err());
451        assert!(!cas.path_for(&digest).unwrap().exists());
452    }
453
454    #[test]
455    fn a_stored_file_is_hashed_from_the_source_and_matches_on_disk() {
456        let directory = tempfile::tempdir().unwrap();
457        let cas = LocalCas::new(directory.path().join("cache"));
458        let source = directory.path().join("source");
459        let bytes = vec![7u8; 3 * 1024 * 1024];
460        fs::write(&source, &bytes).unwrap();
461        let digest = CacheDigest::blake3(&bytes);
462
463        let stored = cas.store_file(&digest, &source).unwrap();
464
465        assert_eq!(fs::read(&stored).unwrap(), bytes);
466        assert!(digest.matches_file(&stored).unwrap());
467        assert_eq!(cas.find(&digest).unwrap(), Some(stored));
468    }
469
470    #[test]
471    fn a_source_that_changed_is_not_the_same_identity() {
472        let directory = tempfile::tempdir().unwrap();
473        let source = directory.path().join("source");
474        fs::write(&source, b"cached object").unwrap();
475        let before = fs::metadata(&source).unwrap();
476        assert!(same_identity(&before, &fs::metadata(&source).unwrap()));
477
478        fs::write(&source, b"other object!").unwrap();
479        // Windows only lets a handle opened for writing change the times.
480        let file = fs::OpenOptions::new().write(true).open(&source).unwrap();
481        file.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
482            .unwrap();
483        drop(file);
484        assert!(!same_identity(&before, &fs::metadata(&source).unwrap()));
485    }
486
487    #[test]
488    fn stores_read_only_source_files() {
489        let directory = tempfile::tempdir().unwrap();
490        let cas = LocalCas::new(directory.path().join("cache"));
491        let source = directory.path().join("source");
492        fs::write(&source, b"cached object").unwrap();
493        let mut permissions = fs::metadata(&source).unwrap().permissions();
494        permissions.set_readonly(true);
495        fs::set_permissions(&source, permissions).unwrap();
496        let digest = CacheDigest::blake3(b"cached object");
497
498        let stored = cas.store_file(&digest, &source).unwrap();
499
500        assert_eq!(fs::read(stored).unwrap(), b"cached object");
501        assert!(fs::metadata(&source).unwrap().permissions().readonly());
502        make_owner_writable(&source).unwrap();
503    }
504
505    #[test]
506    fn rejects_corrupt_existing_blobs() {
507        let directory = tempfile::tempdir().unwrap();
508        let cas = LocalCas::new(directory.path());
509        let digest = CacheDigest::blake3(b"cached object");
510        let path = cas.store_bytes(&digest, b"cached object").unwrap();
511        fs::write(path, b"corrupt").unwrap();
512
513        assert!(cas.find(&digest).is_err());
514    }
515
516    #[test]
517    fn republishes_over_a_corrupt_blob() {
518        let directory = tempfile::tempdir().unwrap();
519        let cas = LocalCas::new(directory.path());
520        let digest = CacheDigest::blake3(b"cached object");
521        let path = cas.store_bytes(&digest, b"cached object").unwrap();
522        fs::write(&path, b"corrupt").unwrap();
523
524        assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
525        assert_eq!(fs::read(&path).unwrap(), b"cached object");
526        assert_eq!(cas.find(&digest).unwrap(), Some(path));
527    }
528
529    #[test]
530    fn republishes_a_file_over_a_corrupt_blob() {
531        let directory = tempfile::tempdir().unwrap();
532        let cas = LocalCas::new(directory.path().join("cache"));
533        let source = directory.path().join("source");
534        fs::write(&source, b"cached object").unwrap();
535        let digest = CacheDigest::blake3(b"cached object");
536        let path = cas.store_file(&digest, &source).unwrap();
537        fs::write(&path, b"corrupt").unwrap();
538
539        assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
540        assert_eq!(fs::read(&path).unwrap(), b"cached object");
541        assert_eq!(cas.find(&digest).unwrap(), Some(path));
542    }
543
544    #[test]
545    fn publishes_action_results_after_referenced_blobs() {
546        let directory = tempfile::tempdir().unwrap();
547        let cas = LocalCas::new(directory.path());
548        let actions = LocalActionCache::new(directory.path());
549        let action = CacheDigest::blake3(b"action");
550        let metadata = CacheDigest::blake3(b"metadata");
551        let output_root = CacheDigest::blake3(b"directory");
552        let result = RemoteActionResult {
553            action: action.clone(),
554            metadata: Some(metadata.clone()),
555            output_root: Some(output_root.clone()),
556            version: 1,
557        };
558
559        assert!(actions.store(&result).is_err());
560        cas.store_bytes(&action, b"action").unwrap();
561        cas.store_bytes(&metadata, b"metadata").unwrap();
562        cas.store_bytes(&output_root, b"directory").unwrap();
563        actions.store(&result).unwrap();
564        assert_eq!(actions.find(&action).unwrap(), Some(result));
565    }
566
567    #[test]
568    fn atomically_replaces_a_corrupt_action_result() {
569        let directory = tempfile::tempdir().unwrap();
570        let cas = LocalCas::new(directory.path());
571        let actions = LocalActionCache::new(directory.path());
572        let action = CacheDigest::blake3(b"action");
573        let result = RemoteActionResult {
574            action: action.clone(),
575            metadata: None,
576            output_root: None,
577            version: 1,
578        };
579        cas.store_bytes(&action, b"action").unwrap();
580        let path = actions.path_for(&action).unwrap();
581        fs::create_dir_all(path.parent().unwrap()).unwrap();
582        fs::write(&path, b"truncated").unwrap();
583
584        assert!(actions.find(&action).is_err());
585        assert_eq!(actions.store(&result).unwrap(), path);
586        assert_eq!(actions.find(&action).unwrap(), Some(result));
587    }
588
589    #[test]
590    fn keeps_a_different_result_until_its_objects_are_gone() {
591        let directory = tempfile::tempdir().unwrap();
592        let cas = LocalCas::new(directory.path());
593        let actions = LocalActionCache::new(directory.path());
594        let action = CacheDigest::blake3(b"action");
595        cas.store_bytes(&action, b"action").unwrap();
596        let publish = |name: &[u8], contents: &[u8]| {
597            let file = CacheDigest::blake3(contents);
598            cas.store_bytes(&file, contents).unwrap();
599            let listing = canonical_json(&crate::CacheDirectory {
600                directories: Vec::new(),
601                files: vec![crate::CacheFileNode {
602                    digest: file.clone(),
603                    executable: false,
604                    mode: 0o644,
605                    name: String::from_utf8(name.to_vec()).unwrap(),
606                }],
607                symlinks: Vec::new(),
608                version: 1,
609            })
610            .unwrap();
611            let output_root = CacheDigest::blake3(&listing);
612            let metadata = CacheDigest::blake3(name);
613            cas.store_bytes(&output_root, &listing).unwrap();
614            cas.store_bytes(&metadata, name).unwrap();
615            (
616                file,
617                RemoteActionResult {
618                    action: action.clone(),
619                    metadata: Some(metadata),
620                    output_root: Some(output_root),
621                    version: 1,
622                },
623            )
624        };
625        let (file, first) = publish(b"libx.rlib", b"built in checkout a");
626        let (_, second) = publish(b"libx.rlib", b"built in checkout bb");
627
628        actions.store(&first).unwrap();
629        let error = actions.store(&second).unwrap_err();
630        assert!(error.to_string().contains("different result"));
631        assert_eq!(actions.find(&action).unwrap(), Some(first));
632
633        fs::remove_file(cas.path_for(&file).unwrap()).unwrap();
634        actions.store(&second).unwrap();
635        assert_eq!(actions.find(&action).unwrap(), Some(second));
636    }
637}