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)]
245fn make_owner_writable(path: &Path) -> Result<()> {
246 let mut permissions = fs::metadata(path)?.permissions();
247 permissions.set_readonly(false);
248 fs::set_permissions(path, permissions)?;
249 Ok(())
250}
251
252impl LocalActionCache {
253 pub fn new(root: impl Into<PathBuf>) -> Self {
255 let root = root.into();
256 Self {
257 cas: LocalCas::new(root.clone()),
258 root,
259 }
260 }
261
262 pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
264 action.validate()?;
265 if action.algorithm != "blake3" {
266 bail!("local action keys must use blake3");
267 }
268 Ok(self
269 .root
270 .join("action-results/v1")
271 .join(&action.algorithm)
272 .join(&action.hash[..2])
273 .join(format!("{}-{}.json", action.hash, action.size)))
274 }
275
276 pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
278 let path = self.path_for(action)?;
279 if !path.exists() {
280 return Ok(None);
281 }
282 let bytes = fs::read(&path)?;
283 let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
284 if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
285 bail!("local action result is invalid: {}", path.display());
286 }
287 Ok(Some(result))
288 }
289
290 fn is_intact(&self, result: &RemoteActionResult) -> bool {
294 let present = |digest: &CacheDigest| matches!(self.cas.find(digest), Ok(Some(_)));
295 present(&result.action)
296 && result.metadata.as_ref().is_none_or(present)
297 && result.output_root.as_ref().is_none_or(|root| {
298 self.directory_is_intact(root, &mut std::collections::BTreeSet::new())
299 })
300 }
301
302 fn directory_is_intact(
305 &self,
306 digest: &CacheDigest,
307 seen: &mut std::collections::BTreeSet<CacheDigest>,
308 ) -> bool {
309 if !seen.insert(digest.clone()) {
310 return true;
311 }
312 let Ok(Some(path)) = self.cas.find(digest) else {
313 return false;
314 };
315 let Ok(bytes) = fs::read(path) else {
316 return false;
317 };
318 let Ok(directory) = serde_json::from_slice::<crate::CacheDirectory>(&bytes) else {
319 return false;
320 };
321 directory
322 .files
323 .iter()
324 .all(|file| matches!(self.cas.find(&file.digest), Ok(Some(_))))
325 && directory
326 .directories
327 .iter()
328 .all(|child| self.directory_is_intact(&child.digest, seen))
329 }
330
331 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
333 if result.version != 1 {
334 bail!("unsupported local action result version");
335 }
336 for digest in [
337 Some(&result.action),
338 result.metadata.as_ref(),
339 result.output_root.as_ref(),
340 ]
341 .into_iter()
342 .flatten()
343 {
344 if self.cas.find(digest)?.is_none() {
345 bail!("cannot publish an action result with a missing blob");
346 }
347 }
348 let destination = self.path_for(&result.action)?;
349 let replace_invalid = match self.find(&result.action) {
350 Ok(Some(existing)) => {
351 if existing == *result {
352 return Ok(destination);
353 }
354 if self.is_intact(&existing) {
358 bail!("local action key already has a different result");
359 }
360 true
361 }
362 Ok(None) => false,
363 Err(_) => true,
364 };
365 let parent = destination
366 .parent()
367 .expect("action-result path has a parent");
368 fs::create_dir_all(parent)?;
369 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
370 temporary.write_all(&canonical_json(result)?)?;
371 temporary.flush()?;
372 if replace_invalid {
373 temporary
374 .persist(&destination)
375 .map_err(|error| error.error)?;
376 return Ok(destination);
377 }
378 match temporary.persist_noclobber(&destination) {
379 Ok(_) => Ok(destination),
380 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
381 .find(&result.action)?
382 .filter(|existing| existing == result)
383 .map(|_| destination)
384 .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
385 Err(error) => Err(error.error.into()),
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn stores_and_validates_blobs_atomically() {
396 let directory = tempfile::tempdir().unwrap();
397 let cas = LocalCas::new(directory.path());
398 let digest = CacheDigest::blake3(b"cached object");
399
400 let path = cas.store_bytes(&digest, b"cached object").unwrap();
401 assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
402 assert_eq!(fs::read(&path).unwrap(), b"cached object");
403 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
404 assert!(cas.store_bytes(&digest, b"other object").is_err());
405 }
406
407 #[test]
408 fn stored_files_are_independent_from_the_source() {
409 let directory = tempfile::tempdir().unwrap();
410 let cas = LocalCas::new(directory.path().join("cache"));
411 let source = directory.path().join("source");
412 fs::write(&source, b"cached object").unwrap();
413 let digest = CacheDigest::blake3(b"cached object");
414
415 let stored = cas.store_file(&digest, &source).unwrap();
416 fs::write(source, b"other object!").unwrap();
417
418 assert_eq!(fs::read(stored).unwrap(), b"cached object");
419 assert!(cas.find(&digest).unwrap().is_some());
420 }
421
422 #[test]
423 fn adopts_verified_files_without_leaving_the_staging_copy() {
424 let directory = tempfile::tempdir().unwrap();
425 let cas = LocalCas::new(directory.path().join("cache"));
426 let staging = directory.path().join("remote");
427 fs::create_dir(&staging).unwrap();
428 let source = staging.join("blob");
429 fs::write(&source, b"cached object").unwrap();
430 let digest = CacheDigest::blake3(b"cached object");
431
432 let stored = cas.adopt_verified_file(&digest, &source).unwrap();
433
434 assert!(!source.exists());
435 assert_eq!(fs::read(&stored).unwrap(), b"cached object");
436 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
437 }
438
439 #[test]
440 fn rejects_files_with_the_wrong_digest() {
441 let directory = tempfile::tempdir().unwrap();
442 let cas = LocalCas::new(directory.path().join("cache"));
443 let source = directory.path().join("source");
444 fs::write(&source, b"other object").unwrap();
445 let digest = CacheDigest::blake3(b"cached object");
446
447 assert!(cas.store_file(&digest, &source).is_err());
448 assert!(!cas.path_for(&digest).unwrap().exists());
449 }
450
451 #[test]
452 fn a_stored_file_is_hashed_from_the_source_and_matches_on_disk() {
453 let directory = tempfile::tempdir().unwrap();
454 let cas = LocalCas::new(directory.path().join("cache"));
455 let source = directory.path().join("source");
456 let bytes = vec![7u8; 3 * 1024 * 1024];
457 fs::write(&source, &bytes).unwrap();
458 let digest = CacheDigest::blake3(&bytes);
459
460 let stored = cas.store_file(&digest, &source).unwrap();
461
462 assert_eq!(fs::read(&stored).unwrap(), bytes);
463 assert!(digest.matches_file(&stored).unwrap());
464 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
465 }
466
467 #[test]
468 fn a_source_that_changed_is_not_the_same_identity() {
469 let directory = tempfile::tempdir().unwrap();
470 let source = directory.path().join("source");
471 fs::write(&source, b"cached object").unwrap();
472 let before = fs::metadata(&source).unwrap();
473 assert!(same_identity(&before, &fs::metadata(&source).unwrap()));
474
475 fs::write(&source, b"other object!").unwrap();
476 let file = fs::OpenOptions::new().write(true).open(&source).unwrap();
478 file.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
479 .unwrap();
480 drop(file);
481 assert!(!same_identity(&before, &fs::metadata(&source).unwrap()));
482 }
483
484 #[test]
485 fn stores_read_only_source_files() {
486 let directory = tempfile::tempdir().unwrap();
487 let cas = LocalCas::new(directory.path().join("cache"));
488 let source = directory.path().join("source");
489 fs::write(&source, b"cached object").unwrap();
490 let mut permissions = fs::metadata(&source).unwrap().permissions();
491 permissions.set_readonly(true);
492 fs::set_permissions(&source, permissions).unwrap();
493 let digest = CacheDigest::blake3(b"cached object");
494
495 let stored = cas.store_file(&digest, &source).unwrap();
496
497 assert_eq!(fs::read(stored).unwrap(), b"cached object");
498 assert!(fs::metadata(&source).unwrap().permissions().readonly());
499 make_owner_writable(&source).unwrap();
500 }
501
502 #[test]
503 fn rejects_corrupt_existing_blobs() {
504 let directory = tempfile::tempdir().unwrap();
505 let cas = LocalCas::new(directory.path());
506 let digest = CacheDigest::blake3(b"cached object");
507 let path = cas.store_bytes(&digest, b"cached object").unwrap();
508 fs::write(path, b"corrupt").unwrap();
509
510 assert!(cas.find(&digest).is_err());
511 }
512
513 #[test]
514 fn republishes_over_a_corrupt_blob() {
515 let directory = tempfile::tempdir().unwrap();
516 let cas = LocalCas::new(directory.path());
517 let digest = CacheDigest::blake3(b"cached object");
518 let path = cas.store_bytes(&digest, b"cached object").unwrap();
519 fs::write(&path, b"corrupt").unwrap();
520
521 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
522 assert_eq!(fs::read(&path).unwrap(), b"cached object");
523 assert_eq!(cas.find(&digest).unwrap(), Some(path));
524 }
525
526 #[test]
527 fn republishes_a_file_over_a_corrupt_blob() {
528 let directory = tempfile::tempdir().unwrap();
529 let cas = LocalCas::new(directory.path().join("cache"));
530 let source = directory.path().join("source");
531 fs::write(&source, b"cached object").unwrap();
532 let digest = CacheDigest::blake3(b"cached object");
533 let path = cas.store_file(&digest, &source).unwrap();
534 fs::write(&path, b"corrupt").unwrap();
535
536 assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
537 assert_eq!(fs::read(&path).unwrap(), b"cached object");
538 assert_eq!(cas.find(&digest).unwrap(), Some(path));
539 }
540
541 #[test]
542 fn publishes_action_results_after_referenced_blobs() {
543 let directory = tempfile::tempdir().unwrap();
544 let cas = LocalCas::new(directory.path());
545 let actions = LocalActionCache::new(directory.path());
546 let action = CacheDigest::blake3(b"action");
547 let metadata = CacheDigest::blake3(b"metadata");
548 let output_root = CacheDigest::blake3(b"directory");
549 let result = RemoteActionResult {
550 action: action.clone(),
551 metadata: Some(metadata.clone()),
552 output_root: Some(output_root.clone()),
553 version: 1,
554 };
555
556 assert!(actions.store(&result).is_err());
557 cas.store_bytes(&action, b"action").unwrap();
558 cas.store_bytes(&metadata, b"metadata").unwrap();
559 cas.store_bytes(&output_root, b"directory").unwrap();
560 actions.store(&result).unwrap();
561 assert_eq!(actions.find(&action).unwrap(), Some(result));
562 }
563
564 #[test]
565 fn atomically_replaces_a_corrupt_action_result() {
566 let directory = tempfile::tempdir().unwrap();
567 let cas = LocalCas::new(directory.path());
568 let actions = LocalActionCache::new(directory.path());
569 let action = CacheDigest::blake3(b"action");
570 let result = RemoteActionResult {
571 action: action.clone(),
572 metadata: None,
573 output_root: None,
574 version: 1,
575 };
576 cas.store_bytes(&action, b"action").unwrap();
577 let path = actions.path_for(&action).unwrap();
578 fs::create_dir_all(path.parent().unwrap()).unwrap();
579 fs::write(&path, b"truncated").unwrap();
580
581 assert!(actions.find(&action).is_err());
582 assert_eq!(actions.store(&result).unwrap(), path);
583 assert_eq!(actions.find(&action).unwrap(), Some(result));
584 }
585
586 #[test]
587 fn keeps_a_different_result_until_its_objects_are_gone() {
588 let directory = tempfile::tempdir().unwrap();
589 let cas = LocalCas::new(directory.path());
590 let actions = LocalActionCache::new(directory.path());
591 let action = CacheDigest::blake3(b"action");
592 cas.store_bytes(&action, b"action").unwrap();
593 let publish = |name: &[u8], contents: &[u8]| {
594 let file = CacheDigest::blake3(contents);
595 cas.store_bytes(&file, contents).unwrap();
596 let listing = canonical_json(&crate::CacheDirectory {
597 directories: Vec::new(),
598 files: vec![crate::CacheFileNode {
599 digest: file.clone(),
600 executable: false,
601 mode: 0o644,
602 name: String::from_utf8(name.to_vec()).unwrap(),
603 }],
604 symlinks: Vec::new(),
605 version: 1,
606 })
607 .unwrap();
608 let output_root = CacheDigest::blake3(&listing);
609 let metadata = CacheDigest::blake3(name);
610 cas.store_bytes(&output_root, &listing).unwrap();
611 cas.store_bytes(&metadata, name).unwrap();
612 (
613 file,
614 RemoteActionResult {
615 action: action.clone(),
616 metadata: Some(metadata),
617 output_root: Some(output_root),
618 version: 1,
619 },
620 )
621 };
622 let (file, first) = publish(b"libx.rlib", b"built in checkout a");
623 let (_, second) = publish(b"libx.rlib", b"built in checkout bb");
624
625 actions.store(&first).unwrap();
626 let error = actions.store(&second).unwrap_err();
627 assert!(error.to_string().contains("different result"));
628 assert_eq!(actions.find(&action).unwrap(), Some(first));
629
630 fs::remove_file(cas.path_for(&file).unwrap()).unwrap();
631 actions.store(&second).unwrap();
632 assert_eq!(actions.find(&action).unwrap(), Some(second));
633 }
634}