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