Skip to main content

mise_cache_core/
local.rs

1use crate::{CacheDigest, RemoteActionResult, canonical_json};
2use eyre::{Result, bail};
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7/// A validated, content-addressed store on the local filesystem.
8#[derive(Debug, Clone)]
9pub struct LocalCas {
10    root: PathBuf,
11}
12
13/// A local index from action digests to their referenced cache objects.
14#[derive(Debug, Clone)]
15pub struct LocalActionCache {
16    root: PathBuf,
17    cas: LocalCas,
18}
19
20impl LocalCas {
21    /// Create a local content-addressed store beneath `root`.
22    pub fn new(root: impl Into<PathBuf>) -> Self {
23        Self { root: root.into() }
24    }
25
26    /// Return the root shared by this store and its action-result index.
27    pub fn root(&self) -> &Path {
28        &self.root
29    }
30
31    /// Resolve the storage path for a validated digest.
32    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    /// Find and verify a stored object.
43    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    /// Atomically store bytes after verifying their declared digest.
58    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    /// Atomically store a file after verifying its declared digest.
69    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    /// Create an action-result index beneath `root`.
112    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    /// Resolve the storage path for an action digest.
121    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    /// Find and strictly validate a canonical action result.
135    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    /// Atomically publish an action result after validating all referenced objects.
149    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}