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 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
292 if result.version != 1 {
293 bail!("unsupported local action result version");
294 }
295 for digest in [
296 Some(&result.action),
297 result.metadata.as_ref(),
298 result.output_root.as_ref(),
299 ]
300 .into_iter()
301 .flatten()
302 {
303 if self.cas.find(digest)?.is_none() {
304 bail!("cannot publish an action result with a missing blob");
305 }
306 }
307 let destination = self.path_for(&result.action)?;
308 let replace_invalid = match self.find(&result.action) {
309 Ok(Some(existing)) => {
310 if existing == *result {
311 return Ok(destination);
312 }
313 bail!("local action key already has a different result");
314 }
315 Ok(None) => false,
316 Err(_) => true,
317 };
318 let parent = destination
319 .parent()
320 .expect("action-result path has a parent");
321 fs::create_dir_all(parent)?;
322 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
323 temporary.write_all(&canonical_json(result)?)?;
324 temporary.flush()?;
325 if replace_invalid {
326 temporary
327 .persist(&destination)
328 .map_err(|error| error.error)?;
329 return Ok(destination);
330 }
331 match temporary.persist_noclobber(&destination) {
332 Ok(_) => Ok(destination),
333 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
334 .find(&result.action)?
335 .filter(|existing| existing == result)
336 .map(|_| destination)
337 .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
338 Err(error) => Err(error.error.into()),
339 }
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn stores_and_validates_blobs_atomically() {
349 let directory = tempfile::tempdir().unwrap();
350 let cas = LocalCas::new(directory.path());
351 let digest = CacheDigest::blake3(b"cached object");
352
353 let path = cas.store_bytes(&digest, b"cached object").unwrap();
354 assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
355 assert_eq!(fs::read(&path).unwrap(), b"cached object");
356 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
357 assert!(cas.store_bytes(&digest, b"other object").is_err());
358 }
359
360 #[test]
361 fn stored_files_are_independent_from_the_source() {
362 let directory = tempfile::tempdir().unwrap();
363 let cas = LocalCas::new(directory.path().join("cache"));
364 let source = directory.path().join("source");
365 fs::write(&source, b"cached object").unwrap();
366 let digest = CacheDigest::blake3(b"cached object");
367
368 let stored = cas.store_file(&digest, &source).unwrap();
369 fs::write(source, b"other object!").unwrap();
370
371 assert_eq!(fs::read(stored).unwrap(), b"cached object");
372 assert!(cas.find(&digest).unwrap().is_some());
373 }
374
375 #[test]
376 fn adopts_verified_files_without_leaving_the_staging_copy() {
377 let directory = tempfile::tempdir().unwrap();
378 let cas = LocalCas::new(directory.path().join("cache"));
379 let staging = directory.path().join("remote");
380 fs::create_dir(&staging).unwrap();
381 let source = staging.join("blob");
382 fs::write(&source, b"cached object").unwrap();
383 let digest = CacheDigest::blake3(b"cached object");
384
385 let stored = cas.adopt_verified_file(&digest, &source).unwrap();
386
387 assert!(!source.exists());
388 assert_eq!(fs::read(&stored).unwrap(), b"cached object");
389 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
390 }
391
392 #[test]
393 fn rejects_files_with_the_wrong_digest() {
394 let directory = tempfile::tempdir().unwrap();
395 let cas = LocalCas::new(directory.path().join("cache"));
396 let source = directory.path().join("source");
397 fs::write(&source, b"other object").unwrap();
398 let digest = CacheDigest::blake3(b"cached object");
399
400 assert!(cas.store_file(&digest, &source).is_err());
401 assert!(!cas.path_for(&digest).unwrap().exists());
402 }
403
404 #[test]
405 fn a_stored_file_is_hashed_from_the_source_and_matches_on_disk() {
406 let directory = tempfile::tempdir().unwrap();
407 let cas = LocalCas::new(directory.path().join("cache"));
408 let source = directory.path().join("source");
409 let bytes = vec![7u8; 3 * 1024 * 1024];
410 fs::write(&source, &bytes).unwrap();
411 let digest = CacheDigest::blake3(&bytes);
412
413 let stored = cas.store_file(&digest, &source).unwrap();
414
415 assert_eq!(fs::read(&stored).unwrap(), bytes);
416 assert!(digest.matches_file(&stored).unwrap());
417 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
418 }
419
420 #[test]
421 fn a_source_that_changed_is_not_the_same_identity() {
422 let directory = tempfile::tempdir().unwrap();
423 let source = directory.path().join("source");
424 fs::write(&source, b"cached object").unwrap();
425 let before = fs::metadata(&source).unwrap();
426 assert!(same_identity(&before, &fs::metadata(&source).unwrap()));
427
428 fs::write(&source, b"other object!").unwrap();
429 let file = fs::OpenOptions::new().write(true).open(&source).unwrap();
431 file.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
432 .unwrap();
433 drop(file);
434 assert!(!same_identity(&before, &fs::metadata(&source).unwrap()));
435 }
436
437 #[test]
438 fn stores_read_only_source_files() {
439 let directory = tempfile::tempdir().unwrap();
440 let cas = LocalCas::new(directory.path().join("cache"));
441 let source = directory.path().join("source");
442 fs::write(&source, b"cached object").unwrap();
443 let mut permissions = fs::metadata(&source).unwrap().permissions();
444 permissions.set_readonly(true);
445 fs::set_permissions(&source, permissions).unwrap();
446 let digest = CacheDigest::blake3(b"cached object");
447
448 let stored = cas.store_file(&digest, &source).unwrap();
449
450 assert_eq!(fs::read(stored).unwrap(), b"cached object");
451 assert!(fs::metadata(&source).unwrap().permissions().readonly());
452 make_owner_writable(&source).unwrap();
453 }
454
455 #[test]
456 fn rejects_corrupt_existing_blobs() {
457 let directory = tempfile::tempdir().unwrap();
458 let cas = LocalCas::new(directory.path());
459 let digest = CacheDigest::blake3(b"cached object");
460 let path = cas.store_bytes(&digest, b"cached object").unwrap();
461 fs::write(path, b"corrupt").unwrap();
462
463 assert!(cas.find(&digest).is_err());
464 }
465
466 #[test]
467 fn republishes_over_a_corrupt_blob() {
468 let directory = tempfile::tempdir().unwrap();
469 let cas = LocalCas::new(directory.path());
470 let digest = CacheDigest::blake3(b"cached object");
471 let path = cas.store_bytes(&digest, b"cached object").unwrap();
472 fs::write(&path, b"corrupt").unwrap();
473
474 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
475 assert_eq!(fs::read(&path).unwrap(), b"cached object");
476 assert_eq!(cas.find(&digest).unwrap(), Some(path));
477 }
478
479 #[test]
480 fn republishes_a_file_over_a_corrupt_blob() {
481 let directory = tempfile::tempdir().unwrap();
482 let cas = LocalCas::new(directory.path().join("cache"));
483 let source = directory.path().join("source");
484 fs::write(&source, b"cached object").unwrap();
485 let digest = CacheDigest::blake3(b"cached object");
486 let path = cas.store_file(&digest, &source).unwrap();
487 fs::write(&path, b"corrupt").unwrap();
488
489 assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
490 assert_eq!(fs::read(&path).unwrap(), b"cached object");
491 assert_eq!(cas.find(&digest).unwrap(), Some(path));
492 }
493
494 #[test]
495 fn publishes_action_results_after_referenced_blobs() {
496 let directory = tempfile::tempdir().unwrap();
497 let cas = LocalCas::new(directory.path());
498 let actions = LocalActionCache::new(directory.path());
499 let action = CacheDigest::blake3(b"action");
500 let metadata = CacheDigest::blake3(b"metadata");
501 let output_root = CacheDigest::blake3(b"directory");
502 let result = RemoteActionResult {
503 action: action.clone(),
504 metadata: Some(metadata.clone()),
505 output_root: Some(output_root.clone()),
506 version: 1,
507 };
508
509 assert!(actions.store(&result).is_err());
510 cas.store_bytes(&action, b"action").unwrap();
511 cas.store_bytes(&metadata, b"metadata").unwrap();
512 cas.store_bytes(&output_root, b"directory").unwrap();
513 actions.store(&result).unwrap();
514 assert_eq!(actions.find(&action).unwrap(), Some(result));
515 }
516
517 #[test]
518 fn atomically_replaces_a_corrupt_action_result() {
519 let directory = tempfile::tempdir().unwrap();
520 let cas = LocalCas::new(directory.path());
521 let actions = LocalActionCache::new(directory.path());
522 let action = CacheDigest::blake3(b"action");
523 let result = RemoteActionResult {
524 action: action.clone(),
525 metadata: None,
526 output_root: None,
527 version: 1,
528 };
529 cas.store_bytes(&action, b"action").unwrap();
530 let path = actions.path_for(&action).unwrap();
531 fs::create_dir_all(path.parent().unwrap()).unwrap();
532 fs::write(&path, b"truncated").unwrap();
533
534 assert!(actions.find(&action).is_err());
535 assert_eq!(actions.store(&result).unwrap(), path);
536 assert_eq!(actions.find(&action).unwrap(), Some(result));
537 }
538}