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