Skip to main content

cuenv_tools_oci/
cache.rs

1//! Content-addressed cache for OCI binaries.
2//!
3//! Binaries are stored by their SHA256 digest, ensuring:
4//! - Hermetic builds (same digest = same binary)
5//! - Deduplication across projects
6//! - Fast cache hits without network requests
7
8use std::path::{Path, PathBuf};
9use tracing::{debug, trace};
10
11use crate::Result;
12
13/// Content-addressed cache for OCI binaries.
14///
15/// Default location: `~/.cache/cuenv/oci/`
16///
17/// Structure:
18/// ```text
19/// ~/.cache/cuenv/oci/
20/// ├── blobs/
21/// │   └── sha256/
22/// │       └── abc123...  # Raw layer blobs
23/// └── bin/
24///     └── sha256/
25///         └── def456...  # Extracted binaries
26/// ```
27#[derive(Debug, Clone)]
28pub struct OciCache {
29    root: PathBuf,
30}
31
32impl Default for OciCache {
33    fn default() -> Self {
34        let cache_dir = dirs::cache_dir()
35            .unwrap_or_else(|| PathBuf::from(".cache"))
36            .join("cuenv")
37            .join("oci");
38        Self::new(cache_dir)
39    }
40}
41
42impl OciCache {
43    /// Create a cache at the specified root directory.
44    #[must_use]
45    pub fn new(root: PathBuf) -> Self {
46        Self { root }
47    }
48
49    /// Get the cache root directory.
50    #[must_use]
51    pub fn root(&self) -> &Path {
52        &self.root
53    }
54
55    /// Get the path for a cached blob.
56    #[must_use]
57    pub fn blob_path(&self, digest: &str) -> PathBuf {
58        let (algo, hash) = parse_digest(digest);
59        self.root.join("blobs").join(algo).join(hash)
60    }
61
62    /// Get the directory for a cached binary (by digest).
63    ///
64    /// Binaries are stored as `bin/<algo>/<hash>/<binary_name>` so that
65    /// the directory can be added to PATH and the binary called by name.
66    #[must_use]
67    pub fn binary_dir(&self, digest: &str) -> PathBuf {
68        let (algo, hash) = parse_digest(digest);
69        self.root.join("bin").join(algo).join(hash)
70    }
71
72    /// Get the full path for a cached binary with its name.
73    #[must_use]
74    pub fn binary_path(&self, digest: &str, binary_name: &str) -> PathBuf {
75        self.binary_dir(digest).join(binary_name)
76    }
77
78    /// Check if a blob is cached.
79    #[must_use]
80    pub fn has_blob(&self, digest: &str) -> bool {
81        self.blob_path(digest).exists()
82    }
83
84    /// Check if a binary is cached.
85    #[must_use]
86    pub fn has_binary(&self, digest: &str, binary_name: &str) -> bool {
87        self.binary_path(digest, binary_name).exists()
88    }
89
90    /// Get a cached binary if it exists.
91    #[must_use]
92    pub fn get_binary(&self, digest: &str, binary_name: &str) -> Option<PathBuf> {
93        let path = self.binary_path(digest, binary_name);
94        if path.exists() {
95            trace!(digest, ?path, "Cache hit for binary");
96            Some(path)
97        } else {
98            trace!(digest, "Cache miss for binary");
99            None
100        }
101    }
102
103    /// Store a blob in the cache.
104    ///
105    /// The blob is moved to the cache location.
106    pub fn store_blob(&self, digest: &str, source: &Path) -> Result<PathBuf> {
107        let dest = self.blob_path(digest);
108        self.store_file(source, &dest)?;
109        debug!(digest, ?dest, "Stored blob in cache");
110        Ok(dest)
111    }
112
113    /// Store a binary in the cache.
114    ///
115    /// The binary is copied to the cache location with its proper name.
116    pub fn store_binary(&self, digest: &str, binary_name: &str, source: &Path) -> Result<PathBuf> {
117        let dest = self.binary_path(digest, binary_name);
118        self.store_file(source, &dest)?;
119        debug!(digest, binary_name, ?dest, "Stored binary in cache");
120        Ok(dest)
121    }
122
123    /// Store a file in the cache, creating parent directories.
124    fn store_file(&self, source: &Path, dest: &Path) -> Result<()> {
125        if let Some(parent) = dest.parent() {
126            std::fs::create_dir_all(parent)?;
127        }
128        std::fs::copy(source, dest)?;
129        Ok(())
130    }
131
132    /// Ensure cache directories exist.
133    pub fn ensure_dirs(&self) -> Result<()> {
134        std::fs::create_dir_all(self.root.join("blobs").join("sha256"))?;
135        std::fs::create_dir_all(self.root.join("bin").join("sha256"))?;
136        Ok(())
137    }
138}
139
140/// Parse a digest string into (algorithm, hash).
141///
142/// Examples:
143/// - "sha256:abc123" -> ("sha256", "abc123")
144/// - "abc123" -> ("sha256", "abc123")
145fn parse_digest(digest: &str) -> (&str, &str) {
146    if let Some((algo, hash)) = digest.split_once(':') {
147        (algo, hash)
148    } else {
149        ("sha256", digest)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use tempfile::TempDir;
157
158    #[test]
159    fn test_cache_paths() {
160        let cache = OciCache::new(PathBuf::from("/tmp/cache"));
161
162        assert_eq!(
163            cache.blob_path("sha256:abc123"),
164            PathBuf::from("/tmp/cache/blobs/sha256/abc123")
165        );
166        assert_eq!(
167            cache.binary_dir("sha256:def456"),
168            PathBuf::from("/tmp/cache/bin/sha256/def456")
169        );
170        assert_eq!(
171            cache.binary_path("sha256:def456", "jq"),
172            PathBuf::from("/tmp/cache/bin/sha256/def456/jq")
173        );
174    }
175
176    #[test]
177    fn test_cache_store_and_get() -> Result<()> {
178        let temp = TempDir::new()?;
179        let cache = OciCache::new(temp.path().to_path_buf());
180        cache.ensure_dirs()?;
181
182        // Create a test file
183        let test_file = temp.path().join("test_binary");
184        std::fs::write(&test_file, b"test content")?;
185
186        // Store as binary with name
187        let digest = "sha256:abc123";
188        let binary_name = "jq";
189        let cached_path = cache.store_binary(digest, binary_name, &test_file)?;
190
191        // Verify cache hit
192        assert!(cache.has_binary(digest, binary_name));
193        assert_eq!(cache.get_binary(digest, binary_name), Some(cached_path));
194
195        // Verify content
196        let content = std::fs::read(cache.binary_path(digest, binary_name))?;
197        assert_eq!(content, b"test content");
198
199        Ok(())
200    }
201
202    #[test]
203    fn test_parse_digest() {
204        assert_eq!(parse_digest("sha256:abc123"), ("sha256", "abc123"));
205        assert_eq!(parse_digest("sha512:def456"), ("sha512", "def456"));
206        assert_eq!(parse_digest("abc123"), ("sha256", "abc123"));
207    }
208
209    #[test]
210    fn test_cache_default() {
211        let cache = OciCache::default();
212        // Default cache should be in user's cache directory
213        let root = cache.root();
214        assert!(root.to_string_lossy().contains("oci"));
215    }
216
217    #[test]
218    fn test_cache_root() {
219        let cache = OciCache::new(PathBuf::from("/custom/cache"));
220        assert_eq!(cache.root(), Path::new("/custom/cache"));
221    }
222
223    #[test]
224    fn test_cache_clone() {
225        let cache = OciCache::new(PathBuf::from("/tmp/test"));
226        let cloned = cache.clone();
227        assert_eq!(cache.root(), cloned.root());
228    }
229
230    #[test]
231    fn test_cache_debug() {
232        let cache = OciCache::new(PathBuf::from("/tmp/test"));
233        let debug = format!("{cache:?}");
234        assert!(debug.contains("OciCache"));
235        assert!(debug.contains("/tmp/test"));
236    }
237
238    #[test]
239    fn test_has_blob_missing() {
240        let temp = TempDir::new().unwrap();
241        let cache = OciCache::new(temp.path().to_path_buf());
242        assert!(!cache.has_blob("sha256:nonexistent"));
243    }
244
245    #[test]
246    fn test_has_binary_missing() {
247        let temp = TempDir::new().unwrap();
248        let cache = OciCache::new(temp.path().to_path_buf());
249        assert!(!cache.has_binary("sha256:nonexistent", "missing"));
250    }
251
252    #[test]
253    fn test_get_binary_missing() {
254        let temp = TempDir::new().unwrap();
255        let cache = OciCache::new(temp.path().to_path_buf());
256        assert!(cache.get_binary("sha256:nonexistent", "missing").is_none());
257    }
258
259    #[test]
260    fn test_store_blob() -> Result<()> {
261        let temp = TempDir::new()?;
262        let cache = OciCache::new(temp.path().to_path_buf());
263
264        // Create a test file
265        let source = temp.path().join("source_blob");
266        std::fs::write(&source, b"blob data")?;
267
268        let digest = "sha256:blobhash123";
269        let stored = cache.store_blob(digest, &source)?;
270
271        assert!(cache.has_blob(digest));
272        assert_eq!(stored, cache.blob_path(digest));
273
274        let content = std::fs::read(&stored)?;
275        assert_eq!(content, b"blob data");
276
277        Ok(())
278    }
279
280    #[test]
281    fn test_ensure_dirs() -> Result<()> {
282        let temp = TempDir::new()?;
283        let cache = OciCache::new(temp.path().to_path_buf());
284        cache.ensure_dirs()?;
285
286        assert!(temp.path().join("blobs").join("sha256").exists());
287        assert!(temp.path().join("bin").join("sha256").exists());
288
289        Ok(())
290    }
291
292    #[test]
293    fn test_blob_path_without_prefix() {
294        let cache = OciCache::new(PathBuf::from("/tmp/cache"));
295        // When no prefix, defaults to sha256
296        assert_eq!(
297            cache.blob_path("abc123"),
298            PathBuf::from("/tmp/cache/blobs/sha256/abc123")
299        );
300    }
301
302    #[test]
303    fn test_binary_dir_without_prefix() {
304        let cache = OciCache::new(PathBuf::from("/tmp/cache"));
305        assert_eq!(
306            cache.binary_dir("xyz789"),
307            PathBuf::from("/tmp/cache/bin/sha256/xyz789")
308        );
309    }
310
311    #[test]
312    fn test_parse_digest_sha512() {
313        let (algo, hash) = parse_digest("sha512:longhashvalue");
314        assert_eq!(algo, "sha512");
315        assert_eq!(hash, "longhashvalue");
316    }
317}