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