1use crate::{CacheDigest, RemoteActionResult, canonical_json};
2use eyre::{Result, bail};
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct LocalCas {
10 root: PathBuf,
11}
12
13#[derive(Debug, Clone)]
15pub struct LocalActionCache {
16 root: PathBuf,
17 cas: LocalCas,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ActionStoreOutcome {
23 Stored,
25 Unchanged,
27 KeptExisting,
29}
30
31impl LocalCas {
32 pub fn new(root: impl Into<PathBuf>) -> Self {
34 Self { root: root.into() }
35 }
36
37 pub fn root(&self) -> &Path {
39 &self.root
40 }
41
42 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 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 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 pub fn store_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
81 self.store_file_inner(digest, source, true)
82 }
83
84 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 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 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 let replace_invalid = match self.find(digest) {
133 Ok(Some(existing)) => return Ok(existing),
134 Ok(None) => false,
135 Err(_) => true,
136 };
137 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 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 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
223fn 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#[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 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 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 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 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 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 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
347 self.store_inner(result, false, &|_| true)?;
348 self.path_for(&result.action)
349 }
350
351 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 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 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}