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