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 if !digest.matches_file(source)? {
71 bail!(
72 "file does not match the declared CAS digest: {}",
73 source.display()
74 );
75 }
76 self.store_with(digest, |temporary| {
77 fs::copy(source, temporary.path())?;
78 Ok(())
79 })
80 }
81
82 fn store_with(
83 &self,
84 digest: &CacheDigest,
85 write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
86 ) -> Result<PathBuf> {
87 let destination = self.path_for(digest)?;
88 if let Some(existing) = self.find(digest)? {
89 return Ok(existing);
90 }
91 let parent = destination.parent().expect("CAS path has a parent");
92 fs::create_dir_all(parent)?;
93 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
94 write(&mut temporary)?;
95 temporary.flush()?;
96 temporary.as_file().sync_all()?;
97 if !digest.matches_file(temporary.path())? {
98 bail!("staged blob does not match the declared CAS digest");
99 }
100 match temporary.persist_noclobber(&destination) {
101 Ok(_) => Ok(destination),
102 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
103 .find(digest)?
104 .ok_or_else(|| eyre::eyre!("concurrent CAS write did not publish a valid blob")),
105 Err(error) => Err(error.error.into()),
106 }
107 }
108}
109
110impl LocalActionCache {
111 pub fn new(root: impl Into<PathBuf>) -> Self {
113 let root = root.into();
114 Self {
115 cas: LocalCas::new(root.clone()),
116 root,
117 }
118 }
119
120 pub fn path_for(&self, action: &CacheDigest) -> Result<PathBuf> {
122 action.validate()?;
123 if action.algorithm != "blake3" {
124 bail!("local action keys must use blake3");
125 }
126 Ok(self
127 .root
128 .join("action-results/v1")
129 .join(&action.algorithm)
130 .join(&action.hash[..2])
131 .join(format!("{}-{}.json", action.hash, action.size)))
132 }
133
134 pub fn find(&self, action: &CacheDigest) -> Result<Option<RemoteActionResult>> {
136 let path = self.path_for(action)?;
137 if !path.exists() {
138 return Ok(None);
139 }
140 let bytes = fs::read(&path)?;
141 let result: RemoteActionResult = serde_json::from_slice(&bytes)?;
142 if result.version != 1 || result.action != *action || canonical_json(&result)? != bytes {
143 bail!("local action result is invalid: {}", path.display());
144 }
145 Ok(Some(result))
146 }
147
148 pub fn store(&self, result: &RemoteActionResult) -> Result<PathBuf> {
150 if result.version != 1 {
151 bail!("unsupported local action result version");
152 }
153 for digest in [
154 Some(&result.action),
155 result.metadata.as_ref(),
156 result.output_root.as_ref(),
157 ]
158 .into_iter()
159 .flatten()
160 {
161 if self.cas.find(digest)?.is_none() {
162 bail!("cannot publish an action result with a missing blob");
163 }
164 }
165 let destination = self.path_for(&result.action)?;
166 let replace_invalid = match self.find(&result.action) {
167 Ok(Some(existing)) => {
168 if existing == *result {
169 return Ok(destination);
170 }
171 bail!("local action key already has a different result");
172 }
173 Ok(None) => false,
174 Err(_) => true,
175 };
176 let parent = destination
177 .parent()
178 .expect("action-result path has a parent");
179 fs::create_dir_all(parent)?;
180 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
181 temporary.write_all(&canonical_json(result)?)?;
182 temporary.flush()?;
183 temporary.as_file().sync_all()?;
184 if replace_invalid {
185 temporary
186 .persist(&destination)
187 .map_err(|error| error.error)?;
188 return Ok(destination);
189 }
190 match temporary.persist_noclobber(&destination) {
191 Ok(_) => Ok(destination),
192 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => self
193 .find(&result.action)?
194 .filter(|existing| existing == result)
195 .map(|_| destination)
196 .ok_or_else(|| eyre::eyre!("concurrent action write was invalid or conflicting")),
197 Err(error) => Err(error.error.into()),
198 }
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn stores_and_validates_blobs_atomically() {
208 let directory = tempfile::tempdir().unwrap();
209 let cas = LocalCas::new(directory.path());
210 let digest = CacheDigest::blake3(b"cached object");
211
212 let path = cas.store_bytes(&digest, b"cached object").unwrap();
213 assert_eq!(cas.find(&digest).unwrap(), Some(path.clone()));
214 assert_eq!(fs::read(&path).unwrap(), b"cached object");
215 assert_eq!(cas.store_bytes(&digest, b"cached object").unwrap(), path);
216 assert!(cas.store_bytes(&digest, b"other object").is_err());
217 }
218
219 #[test]
220 fn rejects_corrupt_existing_blobs() {
221 let directory = tempfile::tempdir().unwrap();
222 let cas = LocalCas::new(directory.path());
223 let digest = CacheDigest::blake3(b"cached object");
224 let path = cas.store_bytes(&digest, b"cached object").unwrap();
225 fs::write(path, b"corrupt").unwrap();
226
227 assert!(cas.find(&digest).is_err());
228 }
229
230 #[test]
231 fn publishes_action_results_after_referenced_blobs() {
232 let directory = tempfile::tempdir().unwrap();
233 let cas = LocalCas::new(directory.path());
234 let actions = LocalActionCache::new(directory.path());
235 let action = CacheDigest::blake3(b"action");
236 let metadata = CacheDigest::blake3(b"metadata");
237 let output_root = CacheDigest::blake3(b"directory");
238 let result = RemoteActionResult {
239 action: action.clone(),
240 metadata: Some(metadata.clone()),
241 output_root: Some(output_root.clone()),
242 version: 1,
243 };
244
245 assert!(actions.store(&result).is_err());
246 cas.store_bytes(&action, b"action").unwrap();
247 cas.store_bytes(&metadata, b"metadata").unwrap();
248 cas.store_bytes(&output_root, b"directory").unwrap();
249 actions.store(&result).unwrap();
250 assert_eq!(actions.find(&action).unwrap(), Some(result));
251 }
252
253 #[test]
254 fn atomically_replaces_a_corrupt_action_result() {
255 let directory = tempfile::tempdir().unwrap();
256 let cas = LocalCas::new(directory.path());
257 let actions = LocalActionCache::new(directory.path());
258 let action = CacheDigest::blake3(b"action");
259 let result = RemoteActionResult {
260 action: action.clone(),
261 metadata: None,
262 output_root: None,
263 version: 1,
264 };
265 cas.store_bytes(&action, b"action").unwrap();
266 let path = actions.path_for(&action).unwrap();
267 fs::create_dir_all(path.parent().unwrap()).unwrap();
268 fs::write(&path, b"truncated").unwrap();
269
270 assert!(actions.find(&action).is_err());
271 assert_eq!(actions.store(&result).unwrap(), path);
272 assert_eq!(actions.find(&action).unwrap(), Some(result));
273 }
274}