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(crate) fn adopt_verified_file(
88 &self,
89 digest: &CacheDigest,
90 source: &Path,
91 ) -> Result<PathBuf> {
92 if fs::metadata(source)?.len() != digest.size {
93 bail!("staged blob size does not match the declared CAS digest");
94 }
95 let destination = self.path_for(digest)?;
96 match self.find(digest) {
97 Ok(Some(existing)) => return Ok(existing),
98 Err(_) => return self.store_file_inner(digest, source, false),
101 Ok(None) => {}
102 }
103 fs::create_dir_all(destination.parent().expect("CAS path has a parent"))?;
104 make_owner_writable(source)?;
105 match fs::rename(source, &destination) {
106 Ok(()) => Ok(destination),
107 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => self
108 .find(digest)?
109 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
110 Err(_) => self.store_file_inner(digest, source, false),
111 }
112 }
113
114 fn store_file_inner(
115 &self,
116 digest: &CacheDigest,
117 source: &Path,
118 verify: bool,
119 ) -> Result<PathBuf> {
120 let destination = self.path_for(digest)?;
121 let replace_invalid = match self.find(digest) {
127 Ok(Some(existing)) => return Ok(existing),
128 Ok(None) => false,
129 Err(_) => true,
130 };
131 let parent = destination.parent().expect("CAS path has a parent");
132 fs::create_dir_all(parent)?;
133 let staging = tempfile::tempdir_in(parent)?;
134 let temporary = staging.path().join("blob");
135 reflink_copy::reflink_or_copy(source, &temporary)?;
136 let temporary = tempfile::TempPath::try_from_path(temporary)?;
137 make_owner_writable(&temporary)?;
138 if verify && !digest.matches_file(&temporary)? {
141 bail!("staged blob does not match the declared CAS digest");
142 }
143 if fs::metadata(&temporary)?.len() != digest.size {
144 bail!("staged blob size does not match the declared CAS digest");
145 }
146 if replace_invalid {
147 temporary
148 .persist(&destination)
149 .map_err(|error| error.error)?;
150 return Ok(destination);
151 }
152 match temporary.persist_noclobber(&destination) {
153 Ok(()) => Ok(destination),
154 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
155 .find(digest)?
156 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
157 Err(error) => Err(error.error.into()),
158 }
159 }
160
161 fn store_with(
162 &self,
163 digest: &CacheDigest,
164 write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
165 ) -> Result<PathBuf> {
166 let destination = self.path_for(digest)?;
167 let replace_invalid = match self.find(digest) {
173 Ok(Some(existing)) => return Ok(existing),
174 Ok(None) => false,
175 Err(_) => true,
176 };
177 let parent = destination.parent().expect("CAS path has a parent");
178 fs::create_dir_all(parent)?;
179 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
180 write(&mut temporary)?;
181 temporary.flush()?;
182 if !digest.matches_file(temporary.path())? {
183 bail!("staged blob does not match the declared CAS digest");
184 }
185 if replace_invalid {
186 temporary
187 .persist(&destination)
188 .map_err(|error| error.error)?;
189 return Ok(destination);
190 }
191 match temporary.persist_noclobber(&destination) {
192 Ok(_) => Ok(destination),
193 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
194 .find(digest)?
195 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
196 Err(error) => Err(error.error.into()),
197 }
198 }
199}
200
201#[cfg(unix)]
202fn make_owner_writable(path: &Path) -> Result<()> {
203 use std::os::unix::fs::PermissionsExt as _;
204 let mut permissions = fs::metadata(path)?.permissions();
205 permissions.set_mode(permissions.mode() | 0o200);
206 fs::set_permissions(path, permissions)?;
207 Ok(())
208}
209
210#[cfg(windows)]
211fn make_owner_writable(path: &Path) -> Result<()> {
212 let mut permissions = fs::metadata(path)?.permissions();
213 permissions.set_readonly(false);
214 fs::set_permissions(path, permissions)?;
215 Ok(())
216}
217
218impl LocalActionCache {
219 pub fn new(root: impl Into<PathBuf>) -> Self {
221 let root = root.into();
222 Self {
223 cas: LocalCas::new(root.clone()),
224 root,
225 }
226 }
227
228 pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
230 action.validate()?;
231 if action.algorithm != "blake3" {
232 bail!("local action keys must use blake3");
233 }
234 Ok(self
235 .root
236 .join("action-results/v1")
237 .join(&action.algorithm)
238 .join(&action.hash[..2])
239 .join(format!("{}-{}.json", action.hash, action.size)))
240 }
241
242 pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
244 let path = self.path_for(action)?;
245 if !path.exists() {
246 return Ok(None);
247 }
248 let bytes = fs::read(&path)?;
249 let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
250 if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
251 bail!("local action result is invalid: {}", path.display());
252 }
253 Ok(Some(result))
254 }
255
256 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
258 if result.version != 1 {
259 bail!("unsupported local action result version");
260 }
261 for digest in [
262 Some(&result.action),
263 result.metadata.as_ref(),
264 result.output_root.as_ref(),
265 ]
266 .into_iter()
267 .flatten()
268 {
269 if self.cas.find(digest)?.is_none() {
270 bail!("cannot publish an action result with a missing blob");
271 }
272 }
273 let destination = self.path_for(&result.action)?;
274 let replace_invalid = match self.find(&result.action) {
275 Ok(Some(existing)) => {
276 if existing == *result {
277 return Ok(destination);
278 }
279 bail!("local action key already has a different result");
280 }
281 Ok(None) => false,
282 Err(_) => true,
283 };
284 let parent = destination
285 .parent()
286 .expect("action-result path has a parent");
287 fs::create_dir_all(parent)?;
288 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
289 temporary.write_all(&canonical_json(result)?)?;
290 temporary.flush()?;
291 if replace_invalid {
292 temporary
293 .persist(&destination)
294 .map_err(|error| error.error)?;
295 return Ok(destination);
296 }
297 match temporary.persist_noclobber(&destination) {
298 Ok(_) => Ok(destination),
299 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
300 .find(&result.action)?
301 .filter(|existing| existing == result)
302 .map(|_| destination)
303 .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
304 Err(error) => Err(error.error.into()),
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn stores_and_validates_blobs_atomically() {
315 let directory = tempfile::tempdir().unwrap();
316 let cas = LocalCas::new(directory.path());
317 let digest = CacheDigest::blake3(b"cached object");
318
319 let path = cas.store_bytes(&digest, b"cached object").unwrap();
320 assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
321 assert_eq!(fs::read(&path).unwrap(), b"cached object");
322 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
323 assert!(cas.store_bytes(&digest, b"other object").is_err());
324 }
325
326 #[test]
327 fn stored_files_are_independent_from_the_source() {
328 let directory = tempfile::tempdir().unwrap();
329 let cas = LocalCas::new(directory.path().join("cache"));
330 let source = directory.path().join("source");
331 fs::write(&source, b"cached object").unwrap();
332 let digest = CacheDigest::blake3(b"cached object");
333
334 let stored = cas.store_file(&digest, &source).unwrap();
335 fs::write(source, b"other object!").unwrap();
336
337 assert_eq!(fs::read(stored).unwrap(), b"cached object");
338 assert!(cas.find(&digest).unwrap().is_some());
339 }
340
341 #[test]
342 fn adopts_verified_files_without_leaving_the_staging_copy() {
343 let directory = tempfile::tempdir().unwrap();
344 let cas = LocalCas::new(directory.path().join("cache"));
345 let staging = directory.path().join("remote");
346 fs::create_dir(&staging).unwrap();
347 let source = staging.join("blob");
348 fs::write(&source, b"cached object").unwrap();
349 let digest = CacheDigest::blake3(b"cached object");
350
351 let stored = cas.adopt_verified_file(&digest, &source).unwrap();
352
353 assert!(!source.exists());
354 assert_eq!(fs::read(&stored).unwrap(), b"cached object");
355 assert_eq!(cas.find(&digest).unwrap(), Some(stored));
356 }
357
358 #[test]
359 fn rejects_files_with_the_wrong_digest() {
360 let directory = tempfile::tempdir().unwrap();
361 let cas = LocalCas::new(directory.path().join("cache"));
362 let source = directory.path().join("source");
363 fs::write(&source, b"other object").unwrap();
364 let digest = CacheDigest::blake3(b"cached object");
365
366 assert!(cas.store_file(&digest, &source).is_err());
367 assert!(!cas.path_for(&digest).unwrap().exists());
368 }
369
370 #[test]
371 fn stores_read_only_source_files() {
372 let directory = tempfile::tempdir().unwrap();
373 let cas = LocalCas::new(directory.path().join("cache"));
374 let source = directory.path().join("source");
375 fs::write(&source, b"cached object").unwrap();
376 let mut permissions = fs::metadata(&source).unwrap().permissions();
377 permissions.set_readonly(true);
378 fs::set_permissions(&source, permissions).unwrap();
379 let digest = CacheDigest::blake3(b"cached object");
380
381 let stored = cas.store_file(&digest, &source).unwrap();
382
383 assert_eq!(fs::read(stored).unwrap(), b"cached object");
384 assert!(fs::metadata(&source).unwrap().permissions().readonly());
385 make_owner_writable(&source).unwrap();
386 }
387
388 #[test]
389 fn rejects_corrupt_existing_blobs() {
390 let directory = tempfile::tempdir().unwrap();
391 let cas = LocalCas::new(directory.path());
392 let digest = CacheDigest::blake3(b"cached object");
393 let path = cas.store_bytes(&digest, b"cached object").unwrap();
394 fs::write(path, b"corrupt").unwrap();
395
396 assert!(cas.find(&digest).is_err());
397 }
398
399 #[test]
400 fn republishes_over_a_corrupt_blob() {
401 let directory = tempfile::tempdir().unwrap();
402 let cas = LocalCas::new(directory.path());
403 let digest = CacheDigest::blake3(b"cached object");
404 let path = cas.store_bytes(&digest, b"cached object").unwrap();
405 fs::write(&path, b"corrupt").unwrap();
406
407 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
408 assert_eq!(fs::read(&path).unwrap(), b"cached object");
409 assert_eq!(cas.find(&digest).unwrap(), Some(path));
410 }
411
412 #[test]
413 fn republishes_a_file_over_a_corrupt_blob() {
414 let directory = tempfile::tempdir().unwrap();
415 let cas = LocalCas::new(directory.path().join("cache"));
416 let source = directory.path().join("source");
417 fs::write(&source, b"cached object").unwrap();
418 let digest = CacheDigest::blake3(b"cached object");
419 let path = cas.store_file(&digest, &source).unwrap();
420 fs::write(&path, b"corrupt").unwrap();
421
422 assert_eq!(cas.store_file(&digest, &source).unwrap(), path);
423 assert_eq!(fs::read(&path).unwrap(), b"cached object");
424 assert_eq!(cas.find(&digest).unwrap(), Some(path));
425 }
426
427 #[test]
428 fn publishes_action_results_after_referenced_blobs() {
429 let directory = tempfile::tempdir().unwrap();
430 let cas = LocalCas::new(directory.path());
431 let actions = LocalActionCache::new(directory.path());
432 let action = CacheDigest::blake3(b"action");
433 let metadata = CacheDigest::blake3(b"metadata");
434 let output_root = CacheDigest::blake3(b"directory");
435 let result = RemoteActionResult {
436 action: action.clone(),
437 metadata: Some(metadata.clone()),
438 output_root: Some(output_root.clone()),
439 version: 1,
440 };
441
442 assert!(actions.store(&result).is_err());
443 cas.store_bytes(&action, b"action").unwrap();
444 cas.store_bytes(&metadata, b"metadata").unwrap();
445 cas.store_bytes(&output_root, b"directory").unwrap();
446 actions.store(&result).unwrap();
447 assert_eq!(actions.find(&action).unwrap(), Some(result));
448 }
449
450 #[test]
451 fn atomically_replaces_a_corrupt_action_result() {
452 let directory = tempfile::tempdir().unwrap();
453 let cas = LocalCas::new(directory.path());
454 let actions = LocalActionCache::new(directory.path());
455 let action = CacheDigest::blake3(b"action");
456 let result = RemoteActionResult {
457 action: action.clone(),
458 metadata: None,
459 output_root: None,
460 version: 1,
461 };
462 cas.store_bytes(&action, b"action").unwrap();
463 let path = actions.path_for(&action).unwrap();
464 fs::create_dir_all(path.parent().unwrap()).unwrap();
465 fs::write(&path, b"truncated").unwrap();
466
467 assert!(actions.find(&action).is_err());
468 assert_eq!(actions.store(&result).unwrap(), path);
469 assert_eq!(actions.find(&action).unwrap(), Some(result));
470 }
471}