1use crate::{CacheDigest, RemoteActionResult, canonical_json};
2use eyre::{Result, bail};
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7pub const ACTION_CONFLICT_MESSAGE: &str = "local action key already has a different result";
9
10#[derive(Debug, Clone)]
12pub struct LocalCas {
13 root: PathBuf,
14}
15
16#[derive(Debug, Clone)]
18pub struct LocalActionCache {
19 root: PathBuf,
20 cas: LocalCas,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ActionStoreOutcome {
26 Stored,
28 Unchanged,
30 KeptExisting,
32}
33
34impl LocalCas {
35 pub fn new(root: impl Into<PathBuf>) -> Self {
37 Self { root: root.into() }
38 }
39
40 pub fn root(&self) -> &Path {
42 &self.root
43 }
44
45 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 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 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 pub fn store_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
84 self.store_file_inner(digest, source, true)
85 }
86
87 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 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 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 let replace_invalid = match self.find(digest) {
136 Ok(Some(existing)) => return Ok(existing),
137 Ok(None) => false,
138 Err(_) => true,
139 };
140 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 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 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
226fn 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#[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 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 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 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 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 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 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
354 self.store_inner(result, false, &|_| true)?;
355 self.path_for(&result.action)
356 }
357
358 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 let decide = |existing: &RemoteActionResult| -> Result<Option<ActionStoreOutcome>> {
397 if existing == result {
398 return Ok(Some(ActionStoreOutcome::Unchanged));
399 }
400 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 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 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 #[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 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}