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(crate) fn adopt_verified_file(
88        &self,
89        digest: &CacheDigest,
90        source: &Path,
91    ) -> Result<PathBuf> {
92        if fs::metadata(source)?.len() != digest.size {
93            bail!("staged blob size does not match the declared CAS digest");
94        }
95        let destination = self.path_for(digest)?;
96        match self.find(digest) {
97            Ok(Some(existing)) => return Ok(existing),
98            // Preserve the established repair path for a poisoned destination;
99            // replacing it portably needs the copy-based temporary file flow.
100            Err(_) => return self.store_file_inner(digest, source, false),
101            Ok(None) => {}
102        }
103        fs::create_dir_all(destination.parent().expect("CAS path has a parent"))?;
104        make_owner_writable(source)?;
105        match fs::rename(source, &destination) {
106            Ok(()) => Ok(destination),
107            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => self
108                .find(digest)?
109                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
110            Err(_) => self.store_file_inner(digest, source, false),
111        }
112    }
113
114    fn store_file_inner(
115        &self,
116        digest: &CacheDigest,
117        source: &Path,
118        verify: bool,
119    ) -> Result<PathBuf> {
120        let destination = self.path_for(digest)?;
121        // A blob that fails verification cannot be restored from, and nothing
122        // else repairs it: the read path reports an error rather than a miss,
123        // so without republishing over it the digest stays poisoned until
124        // eviction happens to reclaim it. `LocalActionCache::store` already
125        // recovers this way one layer up.
126        let replace_invalid = match self.find(digest) {
127            Ok(Some(existing)) => return Ok(existing),
128            Ok(None) => false,
129            Err(_) => true,
130        };
131        let parent = destination.parent().expect("CAS path has a parent");
132        fs::create_dir_all(parent)?;
133        let staging = tempfile::tempdir_in(parent)?;
134        let temporary = staging.path().join("blob");
135        reflink_copy::reflink_or_copy(source, &temporary)?;
136        let temporary = tempfile::TempPath::try_from_path(temporary)?;
137        make_owner_writable(&temporary)?;
138        // Not fsynced: every read verifies the digest, so a blob torn by a
139        // crash is detected and treated as absent rather than trusted.
140        if verify && !digest.matches_file(&temporary)? {
141            bail!("staged blob does not match the declared CAS digest");
142        }
143        if fs::metadata(&temporary)?.len() != digest.size {
144            bail!("staged blob size does not match the declared CAS digest");
145        }
146        if replace_invalid {
147            temporary
148                .persist(&destination)
149                .map_err(|error| error.error)?;
150            return Ok(destination);
151        }
152        match temporary.persist_noclobber(&destination) {
153            Ok(()) => Ok(destination),
154            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
155                .find(digest)?
156                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
157            Err(error) => Err(error.error.into()),
158        }
159    }
160
161    fn store_with(
162        &self,
163        digest: &CacheDigest,
164        write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
165    ) -> Result<PathBuf> {
166        let destination = self.path_for(digest)?;
167        // A blob that fails verification cannot be restored from, and nothing
168        // else repairs it: the read path reports an error rather than a miss,
169        // so without republishing over it the digest stays poisoned until
170        // eviction happens to reclaim it. `LocalActionCache::store` already
171        // recovers this way one layer up.
172        let replace_invalid = match self.find(digest) {
173            Ok(Some(existing)) => return Ok(existing),
174            Ok(None) => false,
175            Err(_) => true,
176        };
177        let parent = destination.parent().expect("CAS path has a parent");
178        fs::create_dir_all(parent)?;
179        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
180        write(&mut temporary)?;
181        temporary.flush()?;
182        if !digest.matches_file(temporary.path())? {
183            bail!("staged blob does not match the declared CAS digest");
184        }
185        if replace_invalid {
186            temporary
187                .persist(&destination)
188                .map_err(|error| error.error)?;
189            return Ok(destination);
190        }
191        match temporary.persist_noclobber(&destination) {
192            Ok(_) => Ok(destination),
193            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
194                .find(digest)?
195                .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
196            Err(error) => Err(error.error.into()),
197        }
198    }
199}
200
201#[cfg(unix)]
202fn make_owner_writable(path: &Path) -> Result<()> {
203    use std::os::unix::fs::PermissionsExt as _;
204    let mut permissions = fs::metadata(path)?.permissions();
205    permissions.set_mode(permissions.mode() | 0o200);
206    fs::set_permissions(path, permissions)?;
207    Ok(())
208}
209
210#[cfg(windows)]
211fn make_owner_writable(path: &Path) -> Result<()> {
212    let mut permissions = fs::metadata(path)?.permissions();
213    permissions.set_readonly(false);
214    fs::set_permissions(path, permissions)?;
215    Ok(())
216}
217
218impl LocalActionCache {
219    /// Create an action-result index beneath `root`.
220    pub fn new(root: impl Into<PathBuf>) -> Self {
221        let root = root.into();
222        Self {
223            cas: LocalCas::new(root.clone()),
224            root,
225        }
226    }
227
228    /// Resolve the storage path for an action digest.
229    pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
230        action.validate()?;
231        if action.algorithm != "blake3" {
232            bail!("local action keys must use blake3");
233        }
234        Ok(self
235            .root
236            .join("action-results/v1")
237            .join(&action.algorithm)
238            .join(&action.hash[..2])
239            .join(format!("{}-{}.json", action.hash, action.size)))
240    }
241
242    /// Find and strictly validate a canonical action result.
243    pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
244        let path = self.path_for(action)?;
245        if !path.exists() {
246            return Ok(None);
247        }
248        let bytes = fs::read(&path)?;
249        let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
250        if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
251            bail!("local action result is invalid: {}", path.display());
252        }
253        Ok(Some(result))
254    }
255
256    /// Atomically publish an action result after validating all referenced objects.
257    pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
258        if result.version != 1 {
259            bail!("unsupported local action result version");
260        }
261        for digest in [
262            Some(&result.action),
263            result.metadata.as_ref(),
264            result.output_root.as_ref(),
265        ]
266        .into_iter()
267        .flatten()
268        {
269            if self.cas.find(digest)?.is_none() {
270                bail!("cannot publish an action result with a missing blob");
271            }
272        }
273        let destination = self.path_for(&result.action)?;
274        let replace_invalid = match self.find(&result.action) {
275            Ok(Some(existing)) => {
276                if existing == *result {
277                    return Ok(destination);
278                }
279                bail!("local action key already has a different result");
280            }
281            Ok(None) => false,
282            Err(_) => true,
283        };
284        let parent = destination
285            .parent()
286            .expect("action-result path has a parent");
287        fs::create_dir_all(parent)?;
288        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
289        temporary.write_all(&canonical_json(result)?)?;
290        temporary.flush()?;
291        if replace_invalid {
292            temporary
293                .persist(&destination)
294                .map_err(|error| error.error)?;
295            return Ok(destination);
296        }
297        match temporary.persist_noclobber(&destination) {
298            Ok(_) => Ok(destination),
299            Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
300                .find(&result.action)?
301                .filter(|existing| existing == result)
302                .map(|_| destination)
303                .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
304            Err(error) => Err(error.error.into()),
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn stores_and_validates_blobs_atomically() {
315        let directory = tempfile::tempdir().unwrap();
316        let cas = LocalCas::new(directory.path());
317        let digest = CacheDigest::blake3(b"cached object");
318
319        let path = cas.store_bytes(&digest, b"cached object").unwrap();
320        assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
321        assert_eq!(fs::read(&path).unwrap(), b"cached object");
322        assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
323        assert!(cas.store_bytes(&digest, b"other object").is_err());
324    }
325
326    #[test]
327    fn stored_files_are_independent_from_the_source() {
328        let directory = tempfile::tempdir().unwrap();
329        let cas = LocalCas::new(directory.path().join("cache"));
330        let source = directory.path().join("source");
331        fs::write(&source, b"cached object").unwrap();
332        let digest = CacheDigest::blake3(b"cached object");
333
334        let stored = cas.store_file(&digest, &source).unwrap();
335        fs::write(source, b"other object!").unwrap();
336
337        assert_eq!(fs::read(stored).unwrap(), b"cached object");
338        assert!(cas.find(&digest).unwrap().is_some());
339    }
340
341    #[test]
342    fn adopts_verified_files_without_leaving_the_staging_copy() {
343        let directory = tempfile::tempdir().unwrap();
344        let cas = LocalCas::new(directory.path().join("cache"));
345        let staging = directory.path().join("remote");
346        fs::create_dir(&staging).unwrap();
347        let source = staging.join("blob");
348        fs::write(&source, b"cached object").unwrap();
349        let digest = CacheDigest::blake3(b"cached object");
350
351        let stored = cas.adopt_verified_file(&digest, &source).unwrap();
352
353        assert!(!source.exists());
354        assert_eq!(fs::read(&stored).unwrap(), b"cached object");
355        assert_eq!(cas.find(&digest).unwrap(), Some(stored));
356    }
357
358    #[test]
359    fn rejects_files_with_the_wrong_digest() {
360        let directory = tempfile::tempdir().unwrap();
361        let cas = LocalCas::new(directory.path().join("cache"));
362        let source = directory.path().join("source");
363        fs::write(&source, b"other object").unwrap();
364        let digest = CacheDigest::blake3(b"cached object");
365
366        assert!(cas.store_file(&digest, &source).is_err());
367        assert!(!cas.path_for(&digest).unwrap().exists());
368    }
369
370    #[test]
371    fn stores_read_only_source_files() {
372        let directory = tempfile::tempdir().unwrap();
373        let cas = LocalCas::new(directory.path().join("cache"));
374        let source = directory.path().join("source");
375        fs::write(&source, b"cached object").unwrap();
376        let mut permissions = fs::metadata(&source).unwrap().permissions();
377        permissions.set_readonly(true);
378        fs::set_permissions(&source, permissions).unwrap();
379        let digest = CacheDigest::blake3(b"cached object");
380
381        let stored = cas.store_file(&digest, &source).unwrap();
382
383        assert_eq!(fs::read(stored).unwrap(), b"cached object");
384        assert!(fs::metadata(&source).unwrap().permissions().readonly());
385        make_owner_writable(&source).unwrap();
386    }
387
388    #[test]
389    fn rejects_corrupt_existing_blobs() {
390        let directory = tempfile::tempdir().unwrap();
391        let cas = LocalCas::new(directory.path());
392        let digest = CacheDigest::blake3(b"cached object");
393        let path = cas.store_bytes(&digest, b"cached object").unwrap();
394        fs::write(path, b"corrupt").unwrap();
395
396        assert!(cas.find(&digest).is_err());
397    }
398
399    #[test]
400    fn republishes_over_a_corrupt_blob() {
401        let directory = tempfile::tempdir().unwrap();
402        let cas = LocalCas::new(directory.path());
403        let digest = CacheDigest::blake3(b"cached object");
404        let path = cas.store_bytes(&digest, b"cached object").unwrap();
405        fs::write(&path, b"corrupt").unwrap();
406
407        assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
408        assert_eq!(fs::read(&path).unwrap(), b"cached object");
409        assert_eq!(cas.find(&digest).unwrap(), Some(path));
410    }
411
412    #[test]
413    fn republishes_a_file_over_a_corrupt_blob() {
414        let directory = tempfile::tempdir().unwrap();
415        let cas = LocalCas::new(directory.path().join("cache"));
416        let source = directory.path().join("source");
417        fs::write(&source, b"cached object").unwrap();
418        let digest = CacheDigest::blake3(b"cached object");
419        let path = cas.store_file(&digest, &source).unwrap();
420        fs::write(&path, b"corrupt").unwrap();
421
422        assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
423        assert_eq!(fs::read(&path).unwrap(), b"cached object");
424        assert_eq!(cas.find(&digest).unwrap(), Some(path));
425    }
426
427    #[test]
428    fn publishes_action_results_after_referenced_blobs() {
429        let directory = tempfile::tempdir().unwrap();
430        let cas = LocalCas::new(directory.path());
431        let actions = LocalActionCache::new(directory.path());
432        let action = CacheDigest::blake3(b"action");
433        let metadata = CacheDigest::blake3(b"metadata");
434        let output_root = CacheDigest::blake3(b"directory");
435        let result = RemoteActionResult {
436            action: action.clone(),
437            metadata: Some(metadata.clone()),
438            output_root: Some(output_root.clone()),
439            version: 1,
440        };
441
442        assert!(actions.store(&result).is_err());
443        cas.store_bytes(&action, b"action").unwrap();
444        cas.store_bytes(&metadata, b"metadata").unwrap();
445        cas.store_bytes(&output_root, b"directory").unwrap();
446        actions.store(&result).unwrap();
447        assert_eq!(actions.find(&action).unwrap(), Some(result));
448    }
449
450    #[test]
451    fn atomically_replaces_a_corrupt_action_result() {
452        let directory = tempfile::tempdir().unwrap();
453        let cas = LocalCas::new(directory.path());
454        let actions = LocalActionCache::new(directory.path());
455        let action = CacheDigest::blake3(b"action");
456        let result = RemoteActionResult {
457            action: action.clone(),
458            metadata: None,
459            output_root: None,
460            version: 1,
461        };
462        cas.store_bytes(&action, b"action").unwrap();
463        let path = actions.path_for(&action).unwrap();
464        fs::create_dir_all(path.parent().unwrap()).unwrap();
465        fs::write(&path, b"truncated").unwrap();
466
467        assert!(actions.find(&action).is_err());
468        assert_eq!(actions.store(&result).unwrap(), path);
469        assert_eq!(actions.find(&action).unwrap(), Some(result));
470    }
471}