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
20impl LocalCas {
21 pub fn new(root: impl Into<PathBuf>) -> Self {
23 Self { root: root.into() }
24 }
25
26 pub fn root(&self) -> &Path {
28 &self.root
29 }
30
31 pub fn path_for(&self, digest: &CacheDigest) -> Result<PathBuf> {
33 digest.validate()?;
34 Ok(self
35 .root
36 .join("cas/v1")
37 .join(&digest.algorithm)
38 .join(&digest.hash[..2])
39 .join(format!("{}-{}", digest.hash, digest.size)))
40 }
41
42 pub fn find(&self, digest: &CacheDigest) -> Result<Option<PathBuf>> {
44 let path = self.path_for(digest)?;
45 if !path.exists() {
46 return Ok(None);
47 }
48 if !digest.matches_file(&path)? {
49 bail!(
50 "local CAS blob failed digest verification: {}",
51 path.display()
52 );
53 }
54 Ok(Some(path))
55 }
56
57 pub fn store_bytes(&self, digest: &CacheDigest, bytes: &[u8]) -> Result<PathBuf> {
59 if !digest.matches_bytes(bytes)? {
60 bail!("bytes do not match the declared CAS digest");
61 }
62 self.store_with(digest, |temporary| {
63 temporary.write_all(bytes)?;
64 Ok(())
65 })
66 }
67
68 pub fn store_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
70 self.store_file_inner(digest, source, true)
71 }
72
73 pub(crate) fn store_verified_file(
75 &self,
76 digest: &CacheDigest,
77 source: &Path,
78 ) -> Result<PathBuf> {
79 self.store_file_inner(digest, source, false)
80 }
81
82 pub fn adopt_verified_file(&self, digest: &CacheDigest, source: &Path) -> Result<PathBuf> {
88 if fs::metadata(source)?.len() != digest.size {
89 bail!("staged blob size does not match the declared CAS digest");
90 }
91 let destination = self.path_for(digest)?;
92 match self.find(digest) {
93 Ok(Some(existing)) => return Ok(existing),
94 Err(_) => return self.store_file_inner(digest, source, false),
97 Ok(None) => {}
98 }
99 fs::create_dir_all(destination.parent().expect("CAS path has a parent"))?;
100 make_owner_writable(source)?;
101 match fs::rename(source, &destination) {
102 Ok(()) => Ok(destination),
103 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => self
104 .find(digest)?
105 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
106 Err(_) => self.store_file_inner(digest, source, false),
107 }
108 }
109
110 fn store_file_inner(
111 &self,
112 digest: &CacheDigest,
113 source: &Path,
114 verify: bool,
115 ) -> Result<PathBuf> {
116 let destination = self.path_for(digest)?;
117 let replace_invalid = match self.find(digest) {
122 Ok(Some(existing)) => return Ok(existing),
123 Ok(None) => false,
124 Err(_) => true,
125 };
126 let hash_source = verify && cfg!(unix);
137 let before = hash_source.then(|| fs::metadata(source)).transpose()?;
138 if hash_source && !digest.matches_file(source)? {
139 bail!("staged blob does not match the declared CAS digest");
140 }
141 let parent = destination.parent().expect("CAS path has a parent");
142 fs::create_dir_all(parent)?;
143 let staging = tempfile::tempdir_in(parent)?;
144 let temporary = staging.path().join("blob");
145 reflink_copy::reflink_or_copy(source, &temporary)?;
146 let temporary = tempfile::TempPath::try_from_path(temporary)?;
147 make_owner_writable(&temporary)?;
148 if let Some(before) = before
149 && !same_identity(&before, &fs::metadata(source)?)
150 {
151 bail!("source changed while it was being stored in the CAS");
152 }
153 if verify && !hash_source && !digest.matches_file(&temporary)? {
154 bail!("staged blob does not match the declared CAS digest");
155 }
156 if fs::metadata(&temporary)?.len() != digest.size {
159 bail!("staged blob size does not match the declared CAS digest");
160 }
161 if replace_invalid {
162 temporary
163 .persist(&destination)
164 .map_err(|error| error.error)?;
165 return Ok(destination);
166 }
167 match temporary.persist_noclobber(&destination) {
168 Ok(()) => Ok(destination),
169 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
170 .find(digest)?
171 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
172 Err(error) => Err(error.error.into()),
173 }
174 }
175
176 fn store_with(
177 &self,
178 digest: &CacheDigest,
179 write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
180 ) -> Result<PathBuf> {
181 let destination = self.path_for(digest)?;
182 let replace_invalid = match self.find(digest) {
184 Ok(Some(existing)) => return Ok(existing),
185 Ok(None) => false,
186 Err(_) => true,
187 };
188 let parent = destination.parent().expect("CAS path has a parent");
189 fs::create_dir_all(parent)?;
190 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
191 write(&mut temporary)?;
192 temporary.flush()?;
193 if !digest.matches_file(temporary.path())? {
194 bail!("staged blob does not match the declared CAS digest");
195 }
196 if replace_invalid {
197 temporary
198 .persist(&destination)
199 .map_err(|error| error.error)?;
200 return Ok(destination);
201 }
202 match temporary.persist_noclobber(&destination) {
203 Ok(_) => Ok(destination),
204 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
205 .find(digest)?
206 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
207 Err(error) => Err(error.error.into()),
208 }
209 }
210}
211
212fn same_identity(before: &fs::Metadata, after: &fs::Metadata) -> bool {
218 if before.len() != after.len() || before.modified().ok() != after.modified().ok() {
219 return false;
220 }
221 #[cfg(unix)]
222 {
223 use std::os::unix::fs::MetadataExt as _;
224 if before.dev() != after.dev()
225 || before.ino() != after.ino()
226 || before.ctime() != after.ctime()
227 || before.ctime_nsec() != after.ctime_nsec()
228 {
229 return false;
230 }
231 }
232 true
233}
234
235#[cfg(unix)]
236fn make_owner_writable(path: &Path) -> Result<()> {
237 use std::os::unix::fs::PermissionsExt as _;
238 let mut permissions = fs::metadata(path)?.permissions();
239 permissions.set_mode(permissions.mode() | 0o200);
240 fs::set_permissions(path, permissions)?;
241 Ok(())
242}
243
244#[cfg(windows)]
247#[allow(clippy::permissions_set_readonly_false)]
248fn make_owner_writable(path: &Path) -> Result<()> {
249 let mut permissions = fs::metadata(path)?.permissions();
250 permissions.set_readonly(false);
251 fs::set_permissions(path, permissions)?;
252 Ok(())
253}
254
255impl LocalActionCache {
256 pub fn new(root: impl Into<PathBuf>) -> Self {
258 let root = root.into();
259 Self {
260 cas: LocalCas::new(root.clone()),
261 root,
262 }
263 }
264
265 pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
267 action.validate()?;
268 if action.algorithm != "blake3" {
269 bail!("local action keys must use blake3");
270 }
271 Ok(self
272 .root
273 .join("action-results/v1")
274 .join(&action.algorithm)
275 .join(&action.hash[..2])
276 .join(format!("{}-{}.json", action.hash, action.size)))
277 }
278
279 pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
281 let path = self.path_for(action)?;
282 if !path.exists() {
283 return Ok(None);
284 }
285 let bytes = fs::read(&path)?;
286 let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
287 if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
288 bail!("local action result is invalid: {}", path.display());
289 }
290 Ok(Some(result))
291 }
292
293 fn is_intact(&self, result: &RemoteActionResult) -> bool {
297 let present = |digest: &CacheDigest| matches!(self.cas.find(digest), Ok(Some(_)));
298 present(&result.action)
299 && result.metadata.as_ref().is_none_or(present)
300 && result.output_root.as_ref().is_none_or(|root| {
301 self.directory_is_intact(root, &mut std::collections::BTreeSet::new())
302 })
303 }
304
305 fn directory_is_intact(
308 &self,
309 digest: &CacheDigest,
310 seen: &mut std::collections::BTreeSet<CacheDigest>,
311 ) -> bool {
312 if !seen.insert(digest.clone()) {
313 return true;
314 }
315 let Ok(Some(path)) = self.cas.find(digest) else {
316 return false;
317 };
318 let Ok(bytes) = fs::read(path) else {
319 return false;
320 };
321 let Ok(directory) = serde_json::from_slice::<crate::CacheDirectory>(&bytes) else {
322 return false;
323 };
324 directory
325 .files
326 .iter()
327 .all(|file| matches!(self.cas.find(&file.digest), Ok(Some(_))))
328 && directory
329 .directories
330 .iter()
331 .all(|child| self.directory_is_intact(&child.digest, seen))
332 }
333
334 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
336 if result.version != 1 {
337 bail!("unsupported local action result version");
338 }
339 for digest in [
340 Some(&result.action),
341 result.metadata.as_ref(),
342 result.output_root.as_ref(),
343 ]
344 .into_iter()
345 .flatten()
346 {
347 if self.cas.find(digest)?.is_none() {
348 bail!("cannot publish an action result with a missing blob");
349 }
350 }
351 let destination = self.path_for(&result.action)?;
352 let replace_invalid = match self.find(&result.action) {
353 Ok(Some(existing)) => {
354 if existing == *result {
355 return Ok(destination);
356 }
357 if self.is_intact(&existing) {
361 bail!("local action key already has a different result");
362 }
363 true
364 }
365 Ok(None) => false,
366 Err(_) => true,
367 };
368 let parent = destination
369 .parent()
370 .expect("action-result path has a parent");
371 fs::create_dir_all(parent)?;
372 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
373 temporary.write_all(&canonical_json(result)?)?;
374 temporary.flush()?;
375 if replace_invalid {
376 temporary
377 .persist(&destination)
378 .map_err(|error| error.error)?;
379 return Ok(destination);
380 }
381 match temporary.persist_noclobber(&destination) {
382 Ok(_) => Ok(destination),
383 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
384 .find(&result.action)?
385 .filter(|existing| existing == result)
386 .map(|_| destination)
387 .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
388 Err(error) => Err(error.error.into()),
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn stores_and_validates_blobs_atomically() {
399 let directory = tempfile::tempdir().unwrap();
400 let cas = LocalCas::new(directory.path());
401 let digest = CacheDigest::blake3(b"cached object");
402
403 let path = cas.store_bytes(&digest, b"cached object").unwrap();
404 assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
405 assert_eq!(fs::read(&path).unwrap(), b"cached object");
406 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
407 assert!(cas.store_bytes(&digest, b"other object").is_err());
408 }
409
410 #[test]
411 fn stored_files_are_independent_from_the_source() {
412 let directory = tempfile::tempdir().unwrap();
413 let cas = LocalCas::new(directory.path().join("cache"));
414 let source = directory.path().join("source");
415 fs::write(&source, b"cached object").unwrap();
416 let digest = CacheDigest::blake3(b"cached object");
417
418 let stored = cas.store_file(&digest, &source).unwrap();
419 fs::write(source, b"other object!").unwrap();
420
421 assert_eq!(fs::read(stored).unwrap(), b"cached object");
422 assert!(cas.find(&digest).unwrap().is_some());
423 }
424
425 #[test]
426 fn adopts_verified_files_without_leaving_the_staging_copy() {
427 let directory = tempfile::tempdir().unwrap();
428 let cas = LocalCas::new(directory.path().join("cache"));
429 let staging = directory.path().join("remote");
430 fs::create_dir(&staging).unwrap();
431 let source = staging.join("blob");
432 fs::write(&source, b"cached object").unwrap();
433 let digest = CacheDigest::blake3(b"cached object");
434
435 let stored = cas.adopt_verified_file(&digest, &source).unwrap();
436
437 assert!(!source.exists());
438 assert_eq!(fs::read(&stored).unwrap(), b"cached object");
439 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
440 }
441
442 #[test]
443 fn rejects_files_with_the_wrong_digest() {
444 let directory = tempfile::tempdir().unwrap();
445 let cas = LocalCas::new(directory.path().join("cache"));
446 let source = directory.path().join("source");
447 fs::write(&source, b"other object").unwrap();
448 let digest = CacheDigest::blake3(b"cached object");
449
450 assert!(cas.store_file(&digest, &source).is_err());
451 assert!(!cas.path_for(&digest).unwrap().exists());
452 }
453
454 #[test]
455 fn a_stored_file_is_hashed_from_the_source_and_matches_on_disk() {
456 let directory = tempfile::tempdir().unwrap();
457 let cas = LocalCas::new(directory.path().join("cache"));
458 let source = directory.path().join("source");
459 let bytes = vec![7u8; 3 * 1024 * 1024];
460 fs::write(&source, &bytes).unwrap();
461 let digest = CacheDigest::blake3(&bytes);
462
463 let stored = cas.store_file(&digest, &source).unwrap();
464
465 assert_eq!(fs::read(&stored).unwrap(), bytes);
466 assert!(digest.matches_file(&stored).unwrap());
467 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
468 }
469
470 #[test]
471 fn a_source_that_changed_is_not_the_same_identity() {
472 let directory = tempfile::tempdir().unwrap();
473 let source = directory.path().join("source");
474 fs::write(&source, b"cached object").unwrap();
475 let before = fs::metadata(&source).unwrap();
476 assert!(same_identity(&before, &fs::metadata(&source).unwrap()));
477
478 fs::write(&source, b"other object!").unwrap();
479 let file = fs::OpenOptions::new().write(true).open(&source).unwrap();
481 file.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
482 .unwrap();
483 drop(file);
484 assert!(!same_identity(&before, &fs::metadata(&source).unwrap()));
485 }
486
487 #[test]
488 fn stores_read_only_source_files() {
489 let directory = tempfile::tempdir().unwrap();
490 let cas = LocalCas::new(directory.path().join("cache"));
491 let source = directory.path().join("source");
492 fs::write(&source, b"cached object").unwrap();
493 let mut permissions = fs::metadata(&source).unwrap().permissions();
494 permissions.set_readonly(true);
495 fs::set_permissions(&source, permissions).unwrap();
496 let digest = CacheDigest::blake3(b"cached object");
497
498 let stored = cas.store_file(&digest, &source).unwrap();
499
500 assert_eq!(fs::read(stored).unwrap(), b"cached object");
501 assert!(fs::metadata(&source).unwrap().permissions().readonly());
502 make_owner_writable(&source).unwrap();
503 }
504
505 #[test]
506 fn rejects_corrupt_existing_blobs() {
507 let directory = tempfile::tempdir().unwrap();
508 let cas = LocalCas::new(directory.path());
509 let digest = CacheDigest::blake3(b"cached object");
510 let path = cas.store_bytes(&digest, b"cached object").unwrap();
511 fs::write(path, b"corrupt").unwrap();
512
513 assert!(cas.find(&digest).is_err());
514 }
515
516 #[test]
517 fn republishes_over_a_corrupt_blob() {
518 let directory = tempfile::tempdir().unwrap();
519 let cas = LocalCas::new(directory.path());
520 let digest = CacheDigest::blake3(b"cached object");
521 let path = cas.store_bytes(&digest, b"cached object").unwrap();
522 fs::write(&path, b"corrupt").unwrap();
523
524 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
525 assert_eq!(fs::read(&path).unwrap(), b"cached object");
526 assert_eq!(cas.find(&digest).unwrap(), Some(path));
527 }
528
529 #[test]
530 fn republishes_a_file_over_a_corrupt_blob() {
531 let directory = tempfile::tempdir().unwrap();
532 let cas = LocalCas::new(directory.path().join("cache"));
533 let source = directory.path().join("source");
534 fs::write(&source, b"cached object").unwrap();
535 let digest = CacheDigest::blake3(b"cached object");
536 let path = cas.store_file(&digest, &source).unwrap();
537 fs::write(&path, b"corrupt").unwrap();
538
539 assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
540 assert_eq!(fs::read(&path).unwrap(), b"cached object");
541 assert_eq!(cas.find(&digest).unwrap(), Some(path));
542 }
543
544 #[test]
545 fn publishes_action_results_after_referenced_blobs() {
546 let directory = tempfile::tempdir().unwrap();
547 let cas = LocalCas::new(directory.path());
548 let actions = LocalActionCache::new(directory.path());
549 let action = CacheDigest::blake3(b"action");
550 let metadata = CacheDigest::blake3(b"metadata");
551 let output_root = CacheDigest::blake3(b"directory");
552 let result = RemoteActionResult {
553 action: action.clone(),
554 metadata: Some(metadata.clone()),
555 output_root: Some(output_root.clone()),
556 version: 1,
557 };
558
559 assert!(actions.store(&result).is_err());
560 cas.store_bytes(&action, b"action").unwrap();
561 cas.store_bytes(&metadata, b"metadata").unwrap();
562 cas.store_bytes(&output_root, b"directory").unwrap();
563 actions.store(&result).unwrap();
564 assert_eq!(actions.find(&action).unwrap(), Some(result));
565 }
566
567 #[test]
568 fn atomically_replaces_a_corrupt_action_result() {
569 let directory = tempfile::tempdir().unwrap();
570 let cas = LocalCas::new(directory.path());
571 let actions = LocalActionCache::new(directory.path());
572 let action = CacheDigest::blake3(b"action");
573 let result = RemoteActionResult {
574 action: action.clone(),
575 metadata: None,
576 output_root: None,
577 version: 1,
578 };
579 cas.store_bytes(&action, b"action").unwrap();
580 let path = actions.path_for(&action).unwrap();
581 fs::create_dir_all(path.parent().unwrap()).unwrap();
582 fs::write(&path, b"truncated").unwrap();
583
584 assert!(actions.find(&action).is_err());
585 assert_eq!(actions.store(&result).unwrap(), path);
586 assert_eq!(actions.find(&action).unwrap(), Some(result));
587 }
588
589 #[test]
590 fn keeps_a_different_result_until_its_objects_are_gone() {
591 let directory = tempfile::tempdir().unwrap();
592 let cas = LocalCas::new(directory.path());
593 let actions = LocalActionCache::new(directory.path());
594 let action = CacheDigest::blake3(b"action");
595 cas.store_bytes(&action, b"action").unwrap();
596 let publish = |name: &[u8], contents: &[u8]| {
597 let file = CacheDigest::blake3(contents);
598 cas.store_bytes(&file, contents).unwrap();
599 let listing = canonical_json(&crate::CacheDirectory {
600 directories: Vec::new(),
601 files: vec![crate::CacheFileNode {
602 digest: file.clone(),
603 executable: false,
604 mode: 0o644,
605 name: String::from_utf8(name.to_vec()).unwrap(),
606 }],
607 symlinks: Vec::new(),
608 version: 1,
609 })
610 .unwrap();
611 let output_root = CacheDigest::blake3(&listing);
612 let metadata = CacheDigest::blake3(name);
613 cas.store_bytes(&output_root, &listing).unwrap();
614 cas.store_bytes(&metadata, name).unwrap();
615 (
616 file,
617 RemoteActionResult {
618 action: action.clone(),
619 metadata: Some(metadata),
620 output_root: Some(output_root),
621 version: 1,
622 },
623 )
624 };
625 let (file, first) = publish(b"libx.rlib", b"built in checkout a");
626 let (_, second) = publish(b"libx.rlib", b"built in checkout bb");
627
628 actions.store(&first).unwrap();
629 let error = actions.store(&second).unwrap_err();
630 assert!(error.to_string().contains("different result"));
631 assert_eq!(actions.find(&action).unwrap(), Some(first));
632
633 fs::remove_file(cas.path_for(&file).unwrap()).unwrap();
634 actions.store(&second).unwrap();
635 assert_eq!(actions.find(&action).unwrap(), Some(second));
636 }
637}