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