Skip to main content

a3s_box_runtime/cache/
rootfs_cache.rs

1//! Cache for fully-built rootfs directories.
2//!
3//! Avoids rebuilding the rootfs from OCI layers when the same image
4//! configuration has been seen before. The cache key is a SHA256 hash
5//! of the image reference, layer digests, entrypoint, and environment.
6
7use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13/// Metadata for a cached rootfs entry.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RootfsMeta {
16    /// Cache key (SHA256 hex string)
17    pub key: String,
18    /// Human-readable description of what produced this rootfs
19    pub description: String,
20    /// Size of the rootfs directory in bytes
21    pub size_bytes: u64,
22    /// When this rootfs was cached (Unix timestamp)
23    pub cached_at: i64,
24    /// Last time this rootfs was accessed (Unix timestamp)
25    pub last_accessed: i64,
26}
27
28/// Cache for fully-built rootfs directories.
29///
30/// Rootfs entries are stored under `cache_dir/rootfs/<key>/`.
31/// Metadata is stored alongside as `<key>.meta.json`.
32pub struct RootfsCache {
33    /// Root directory for rootfs cache (e.g., ~/.a3s/cache/rootfs)
34    cache_dir: PathBuf,
35}
36
37impl RootfsCache {
38    /// Create a new rootfs cache at the given directory.
39    pub fn new(cache_dir: &Path) -> Result<Self> {
40        std::fs::create_dir_all(cache_dir).map_err(|e| {
41            BoxError::CacheError(format!(
42                "Failed to create rootfs cache directory {}: {}",
43                cache_dir.display(),
44                e
45            ))
46        })?;
47
48        Ok(Self {
49            cache_dir: cache_dir.to_path_buf(),
50        })
51    }
52
53    /// Compute a cache key from image components.
54    ///
55    /// The key is a SHA256 hash of the concatenation of:
56    /// - image reference (e.g., "nginx:latest")
57    /// - sorted layer digests
58    /// - entrypoint
59    /// - sorted environment variables
60    pub fn compute_key(
61        image_ref: &str,
62        layer_digests: &[String],
63        entrypoint: &[String],
64        env: &[(String, String)],
65    ) -> String {
66        let mut hasher = Sha256::new();
67        // v2 excludes OCI-provided overlayfs private xattrs before a cached
68        // directory may become a metacopy lower. Do not reuse v1 entries that
69        // predate that ingestion invariant.
70        hasher.update(b"rootfs-cache-v2\n");
71        hasher.update(image_ref.as_bytes());
72        hasher.update(b"\n");
73
74        for digest in layer_digests {
75            hasher.update(digest.as_bytes());
76            hasher.update(b"\n");
77        }
78
79        for part in entrypoint {
80            hasher.update(part.as_bytes());
81            hasher.update(b"\n");
82        }
83
84        let mut sorted_env: Vec<_> = env.to_vec();
85        sorted_env.sort();
86        for (k, v) in &sorted_env {
87            hasher.update(k.as_bytes());
88            hasher.update(b"=");
89            hasher.update(v.as_bytes());
90            hasher.update(b"\n");
91        }
92
93        hex::encode(hasher.finalize())
94    }
95
96    /// Get the path to a cached rootfs by key.
97    ///
98    /// Returns `None` if the rootfs is not cached or the cache entry is invalid.
99    pub fn get(&self, key: &str) -> Result<Option<PathBuf>> {
100        let rootfs_dir = self.cache_dir.join(key);
101        let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
102
103        if !rootfs_dir.is_dir() || !meta_path.is_file() {
104            return Ok(None);
105        }
106
107        // Update last_accessed timestamp
108        if let Ok(content) = std::fs::read_to_string(&meta_path) {
109            if let Ok(mut meta) = serde_json::from_str::<RootfsMeta>(&content) {
110                meta.last_accessed = chrono::Utc::now().timestamp();
111                if let Err(e) = super::layer_cache::write_meta_atomically(
112                    &meta_path,
113                    &serde_json::to_string_pretty(&meta)?,
114                ) {
115                    tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update rootfs cache metadata");
116                }
117            }
118        }
119
120        Ok(Some(rootfs_dir))
121    }
122
123    /// Store a built rootfs directory in the cache.
124    ///
125    /// Copies the contents of `source_rootfs` into the cache keyed by `key`.
126    /// Returns the path to the cached rootfs directory.
127    pub fn put(&self, key: &str, source_rootfs: &Path, description: &str) -> Result<PathBuf> {
128        let rootfs_dir = self.cache_dir.join(key);
129        let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
130
131        // Already fully cached: nothing to do. `put` is only ever called on a
132        // cache MISS, so the only way an entry already exists here is a
133        // concurrent miss of the SAME image — identical content — which makes
134        // the skip correct and the two pulls idempotent.
135        if rootfs_dir.is_dir() && meta_path.is_file() {
136            return Ok(rootfs_dir);
137        }
138
139        // Atomically publish (staging dir + rename) so two concurrent builds of
140        // the same image cannot corrupt the cache by removing/interleaving a
141        // half-copied directory (same bug as the layer cache, #85).
142        super::layer_cache::publish_dir_atomically(source_rootfs, &rootfs_dir, &self.cache_dir)?;
143
144        // Calculate size (from whichever copy landed — they are identical).
145        let size_bytes = super::layer_cache::dir_size(&rootfs_dir).unwrap_or(0);
146
147        // Write metadata atomically (unique temp + rename).
148        let now = chrono::Utc::now().timestamp();
149        let meta = RootfsMeta {
150            key: key.to_string(),
151            description: description.to_string(),
152            size_bytes,
153            cached_at: now,
154            last_accessed: now,
155        };
156        super::layer_cache::write_meta_atomically(
157            &meta_path,
158            &serde_json::to_string_pretty(&meta)?,
159        )?;
160
161        tracing::debug!(
162            key = %key,
163            description = %description,
164            size_bytes,
165            path = %rootfs_dir.display(),
166            "Cached rootfs"
167        );
168
169        Ok(rootfs_dir)
170    }
171
172    /// Remove a cached rootfs by key.
173    pub fn invalidate(&self, key: &str) -> Result<()> {
174        let rootfs_dir = self.cache_dir.join(key);
175        let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
176
177        if rootfs_dir.exists() {
178            std::fs::remove_dir_all(&rootfs_dir).map_err(|e| {
179                BoxError::CacheError(format!(
180                    "Failed to remove cached rootfs {}: {}",
181                    rootfs_dir.display(),
182                    e
183                ))
184            })?;
185        }
186        if meta_path.exists() {
187            std::fs::remove_file(&meta_path).map_err(|e| {
188                BoxError::CacheError(format!(
189                    "Failed to remove rootfs metadata {}: {}",
190                    meta_path.display(),
191                    e
192                ))
193            })?;
194        }
195
196        Ok(())
197    }
198
199    /// Prune the cache to stay within the given entry count / byte limit.
200    ///
201    /// Evicts least-recently-accessed entries first. Returns the number evicted.
202    pub fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
203        self.prune_protecting(max_entries, max_bytes, &std::collections::HashSet::new())
204    }
205
206    /// Like [`RootfsCache::prune`], but never evicts an entry whose key is in
207    /// `protected`. Such an entry is currently serving as a box's overlayfs
208    /// **lowerdir**, and `remove_dir_all`-ing it out from under a concurrent box's
209    /// `mount(2)` makes the mount fail with ENOENT ("No such file or directory").
210    /// This is the same in-use guard [`crate::SnapshotStore::prune`] applies to
211    /// live copy-on-write lowers — without it, two pipelines built from the same
212    /// image (one cache-hit overlay box, one cache-miss box that prunes after its
213    /// put) can race and corrupt each other.
214    pub fn prune_protecting(
215        &self,
216        max_entries: usize,
217        max_bytes: u64,
218        protected: &std::collections::HashSet<String>,
219    ) -> Result<usize> {
220        let mut entries = self.list_entries()?;
221
222        if entries.len() <= max_entries {
223            let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
224            if total_size <= max_bytes {
225                return Ok(0);
226            }
227        }
228
229        // Sort by last_accessed ascending (oldest first)
230        entries.sort_by_key(|e| e.last_accessed);
231
232        let mut current_count = entries.len();
233        let mut current_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
234        let mut evicted = 0;
235
236        for entry in &entries {
237            if current_count <= max_entries && current_size <= max_bytes {
238                break;
239            }
240            // Never evict an entry in use as a live overlay lower — deleting the
241            // lowerdir under a concurrent box's mount(2) is the bug this guards.
242            if protected.contains(&entry.key) {
243                continue;
244            }
245            self.invalidate(&entry.key)?;
246            current_count -= 1;
247            current_size = current_size.saturating_sub(entry.size_bytes);
248            evicted += 1;
249
250            tracing::debug!(
251                key = %entry.key,
252                description = %entry.description,
253                size_bytes = entry.size_bytes,
254                "Evicted cached rootfs"
255            );
256        }
257
258        Ok(evicted)
259    }
260
261    /// List all cached rootfs entries with their metadata.
262    pub fn list_entries(&self) -> Result<Vec<RootfsMeta>> {
263        let mut entries = Vec::new();
264
265        let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
266            BoxError::CacheError(format!(
267                "Failed to read rootfs cache directory {}: {}",
268                self.cache_dir.display(),
269                e
270            ))
271        })?;
272
273        for entry in read_dir {
274            let entry = entry.map_err(|e| {
275                BoxError::CacheError(format!("Failed to read directory entry: {}", e))
276            })?;
277            let path = entry.path();
278
279            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
280                if name.ends_with(".meta.json") {
281                    if let Ok(content) = std::fs::read_to_string(&path) {
282                        if let Ok(meta) = serde_json::from_str::<RootfsMeta>(&content) {
283                            entries.push(meta);
284                        }
285                    }
286                }
287            }
288        }
289
290        Ok(entries)
291    }
292
293    /// Get the total size of all cached rootfs entries in bytes.
294    pub fn total_size(&self) -> Result<u64> {
295        Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
296    }
297
298    /// Get the number of cached rootfs entries.
299    pub fn entry_count(&self) -> Result<usize> {
300        Ok(self.list_entries()?.len())
301    }
302}
303
304impl a3s_box_core::traits::CacheBackend for RootfsCache {
305    fn get(&self, key: &str) -> Result<Option<PathBuf>> {
306        self.get(key)
307    }
308
309    fn put(&self, key: &str, source_dir: &Path, description: &str) -> Result<PathBuf> {
310        self.put(key, source_dir, description)
311    }
312
313    fn invalidate(&self, key: &str) -> Result<()> {
314        self.invalidate(key)
315    }
316
317    fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
318        self.prune(max_entries, max_bytes)
319    }
320
321    fn list(&self) -> Result<Vec<a3s_box_core::traits::CacheEntry>> {
322        self.list_entries().map(|entries| {
323            entries
324                .into_iter()
325                .map(|m| a3s_box_core::traits::CacheEntry {
326                    key: m.key,
327                    description: m.description,
328                    size_bytes: m.size_bytes,
329                    cached_at: m.cached_at,
330                    last_accessed: m.last_accessed,
331                })
332                .collect()
333        })
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use tempfile::TempDir;
341
342    fn create_test_rootfs(dir: &Path, files: &[(&str, &str)]) {
343        std::fs::create_dir_all(dir).unwrap();
344        for (name, content) in files {
345            let file_path = dir.join(name);
346            if let Some(parent) = file_path.parent() {
347                std::fs::create_dir_all(parent).unwrap();
348            }
349            std::fs::write(&file_path, content).unwrap();
350        }
351    }
352
353    #[test]
354    fn test_rootfs_cache_new_creates_directory() {
355        let tmp = TempDir::new().unwrap();
356        let cache_dir = tmp.path().join("rootfs");
357
358        assert!(!cache_dir.exists());
359        let _cache = RootfsCache::new(&cache_dir).unwrap();
360        assert!(cache_dir.is_dir());
361    }
362
363    #[test]
364    fn test_rootfs_cache_get_miss() {
365        let tmp = TempDir::new().unwrap();
366        let cache = RootfsCache::new(tmp.path()).unwrap();
367
368        let result = cache.get("nonexistent_key").unwrap();
369        assert!(result.is_none());
370    }
371
372    #[test]
373    fn test_rootfs_cache_put_and_get() {
374        let tmp = TempDir::new().unwrap();
375        let cache = RootfsCache::new(tmp.path()).unwrap();
376
377        let source = tmp.path().join("source_rootfs");
378        create_test_rootfs(
379            &source,
380            &[("bin/agent", "binary"), ("etc/config.json", "{}")],
381        );
382
383        let key = "abc123def456";
384        let cached_path = cache.put(key, &source, "test rootfs").unwrap();
385
386        assert!(cached_path.is_dir());
387        assert!(cached_path.join("bin/agent").is_file());
388        assert!(cached_path.join("etc/config.json").is_file());
389
390        let result = cache.get(key).unwrap();
391        assert!(result.is_some());
392        assert_eq!(result.unwrap(), cached_path);
393    }
394
395    #[test]
396    fn test_rootfs_cache_invalidate() {
397        let tmp = TempDir::new().unwrap();
398        let cache = RootfsCache::new(tmp.path()).unwrap();
399        let key = "to_invalidate";
400
401        let source = tmp.path().join("source");
402        create_test_rootfs(&source, &[("data.bin", "data")]);
403        cache.put(key, &source, "temp").unwrap();
404
405        assert!(cache.get(key).unwrap().is_some());
406        cache.invalidate(key).unwrap();
407        assert!(cache.get(key).unwrap().is_none());
408    }
409
410    #[test]
411    fn test_rootfs_cache_invalidate_nonexistent() {
412        let tmp = TempDir::new().unwrap();
413        let cache = RootfsCache::new(tmp.path()).unwrap();
414        cache.invalidate("does_not_exist").unwrap();
415    }
416
417    #[test]
418    fn test_rootfs_cache_list_entries() {
419        let tmp = TempDir::new().unwrap();
420        let cache = RootfsCache::new(tmp.path()).unwrap();
421
422        assert_eq!(cache.list_entries().unwrap().len(), 0);
423
424        let s1 = tmp.path().join("s1");
425        create_test_rootfs(&s1, &[("a.txt", "aaa")]);
426        cache.put("key1", &s1, "first").unwrap();
427
428        let s2 = tmp.path().join("s2");
429        create_test_rootfs(&s2, &[("b.txt", "bbb")]);
430        cache.put("key2", &s2, "second").unwrap();
431
432        let entries = cache.list_entries().unwrap();
433        assert_eq!(entries.len(), 2);
434
435        let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
436        assert!(keys.contains(&"key1"));
437        assert!(keys.contains(&"key2"));
438    }
439
440    #[test]
441    fn test_rootfs_cache_entry_count() {
442        let tmp = TempDir::new().unwrap();
443        let cache = RootfsCache::new(tmp.path()).unwrap();
444
445        assert_eq!(cache.entry_count().unwrap(), 0);
446
447        let source = tmp.path().join("source");
448        create_test_rootfs(&source, &[("f.txt", "data")]);
449        cache.put("k1", &source, "one").unwrap();
450        cache.put("k2", &source, "two").unwrap();
451
452        assert_eq!(cache.entry_count().unwrap(), 2);
453    }
454
455    #[test]
456    fn test_rootfs_cache_total_size() {
457        let tmp = TempDir::new().unwrap();
458        let cache = RootfsCache::new(tmp.path()).unwrap();
459
460        assert_eq!(cache.total_size().unwrap(), 0);
461
462        let source = tmp.path().join("source");
463        create_test_rootfs(&source, &[("data.txt", "hello world")]);
464        cache.put("sized", &source, "sized entry").unwrap();
465
466        assert!(cache.total_size().unwrap() > 0);
467    }
468
469    #[test]
470    fn test_rootfs_cache_prune_by_count() {
471        let tmp = TempDir::new().unwrap();
472        let cache = RootfsCache::new(tmp.path()).unwrap();
473
474        // Add 5 entries
475        for i in 0..5 {
476            let source = tmp.path().join(format!("s{}", i));
477            create_test_rootfs(&source, &[("f.txt", "data")]);
478            cache
479                .put(&format!("key{}", i), &source, &format!("entry {}", i))
480                .unwrap();
481            std::thread::sleep(std::time::Duration::from_millis(10));
482        }
483
484        assert_eq!(cache.entry_count().unwrap(), 5);
485
486        // Prune to max 2 entries
487        let evicted = cache.prune(2, u64::MAX).unwrap();
488        assert_eq!(evicted, 3);
489        assert_eq!(cache.entry_count().unwrap(), 2);
490    }
491
492    #[test]
493    fn test_rootfs_cache_prune_by_size() {
494        let tmp = TempDir::new().unwrap();
495        let cache = RootfsCache::new(tmp.path()).unwrap();
496
497        for i in 0..3 {
498            let source = tmp.path().join(format!("s{}", i));
499            create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
500            cache
501                .put(&format!("key{}", i), &source, &format!("entry {}", i))
502                .unwrap();
503            std::thread::sleep(std::time::Duration::from_millis(10));
504        }
505
506        // Prune to 1 byte — should evict all but possibly one
507        let evicted = cache.prune(usize::MAX, 1).unwrap();
508        assert!(evicted >= 2);
509    }
510
511    #[test]
512    fn test_rootfs_cache_prune_no_eviction_needed() {
513        let tmp = TempDir::new().unwrap();
514        let cache = RootfsCache::new(tmp.path()).unwrap();
515
516        let source = tmp.path().join("source");
517        create_test_rootfs(&source, &[("f.txt", "data")]);
518        cache.put("key1", &source, "entry").unwrap();
519
520        let evicted = cache.prune(10, u64::MAX).unwrap();
521        assert_eq!(evicted, 0);
522        assert_eq!(cache.entry_count().unwrap(), 1);
523    }
524
525    #[test]
526    fn prune_protecting_never_evicts_in_use_key() {
527        let tmp = TempDir::new().unwrap();
528        let cache = RootfsCache::new(tmp.path()).unwrap();
529        for i in 0..4 {
530            let src = tmp.path().join(format!("s{i}"));
531            create_test_rootfs(&src, &[("f", "x")]);
532            cache.put(&format!("k{i}"), &src, &format!("e{i}")).unwrap();
533            std::thread::sleep(std::time::Duration::from_millis(10));
534        }
535        // k0 is the OLDEST (normally evicted first) but is in use as an overlay lower.
536        let mut protected = std::collections::HashSet::new();
537        protected.insert("k0".to_string());
538        // keep=2 over 4 entries evicts two; the protected k0 is never one of them.
539        // (last_accessed is second-resolution, so WHICH two unprotected entries go
540        // is not asserted — only that the in-use lower survives.)
541        let evicted = cache.prune_protecting(2, u64::MAX, &protected).unwrap();
542        assert_eq!(evicted, 2, "two unprotected entries evicted to meet keep=2");
543        assert!(
544            cache.get("k0").unwrap().is_some(),
545            "the in-use (protected) lower must survive prune"
546        );
547        assert_eq!(
548            cache.entry_count().unwrap(),
549            2,
550            "k0 + one unprotected remain"
551        );
552    }
553
554    #[test]
555    fn prune_protecting_keeps_all_when_all_in_use() {
556        let tmp = TempDir::new().unwrap();
557        let cache = RootfsCache::new(tmp.path()).unwrap();
558        for i in 0..2 {
559            let src = tmp.path().join(format!("s{i}"));
560            create_test_rootfs(&src, &[("f", "x")]);
561            cache.put(&format!("k{i}"), &src, "e").unwrap();
562        }
563        let protected: std::collections::HashSet<String> =
564            ["k0", "k1"].iter().map(|s| s.to_string()).collect();
565        // Even asked to keep 0, nothing is evicted — every entry is a live lower.
566        let evicted = cache.prune_protecting(0, 0, &protected).unwrap();
567        assert_eq!(evicted, 0, "all in-use -> nothing evicted");
568        assert_eq!(cache.entry_count().unwrap(), 2);
569    }
570
571    #[test]
572    fn test_rootfs_cache_metadata_persists() {
573        let tmp = TempDir::new().unwrap();
574        let cache = RootfsCache::new(tmp.path()).unwrap();
575        let key = "meta_test";
576
577        let source = tmp.path().join("source");
578        create_test_rootfs(&source, &[("file.txt", "content")]);
579        cache.put(key, &source, "test description").unwrap();
580
581        let meta_path = tmp.path().join(format!("{}.meta.json", key));
582        assert!(meta_path.is_file());
583
584        let content = std::fs::read_to_string(&meta_path).unwrap();
585        let meta: RootfsMeta = serde_json::from_str(&content).unwrap();
586
587        assert_eq!(meta.key, key);
588        assert_eq!(meta.description, "test description");
589        assert!(meta.size_bytes > 0);
590        assert!(meta.cached_at > 0);
591        assert_eq!(meta.cached_at, meta.last_accessed);
592    }
593
594    #[test]
595    fn test_compute_key_deterministic() {
596        let key1 = RootfsCache::compute_key(
597            "nginx:latest",
598            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
599            &["/bin/nginx".to_string()],
600            &[("PATH".to_string(), "/usr/bin".to_string())],
601        );
602        let key2 = RootfsCache::compute_key(
603            "nginx:latest",
604            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
605            &["/bin/nginx".to_string()],
606            &[("PATH".to_string(), "/usr/bin".to_string())],
607        );
608        assert_eq!(key1, key2);
609    }
610
611    #[test]
612    fn test_compute_key_different_inputs() {
613        let key1 = RootfsCache::compute_key("nginx:latest", &[], &[], &[]);
614        let key2 = RootfsCache::compute_key("nginx:1.25", &[], &[], &[]);
615        assert_ne!(key1, key2);
616    }
617
618    #[test]
619    fn test_compute_key_env_order_independent() {
620        let key1 = RootfsCache::compute_key(
621            "img",
622            &[],
623            &[],
624            &[
625                ("A".to_string(), "1".to_string()),
626                ("B".to_string(), "2".to_string()),
627            ],
628        );
629        let key2 = RootfsCache::compute_key(
630            "img",
631            &[],
632            &[],
633            &[
634                ("B".to_string(), "2".to_string()),
635                ("A".to_string(), "1".to_string()),
636            ],
637        );
638        assert_eq!(key1, key2);
639    }
640
641    #[test]
642    fn test_compute_key_is_hex_sha256() {
643        let key = RootfsCache::compute_key("test", &[], &[], &[]);
644        // SHA256 hex is 64 characters
645        assert_eq!(key.len(), 64);
646        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
647    }
648
649    #[test]
650    fn test_compute_key_layer_order_matters() {
651        let key1 = RootfsCache::compute_key(
652            "img",
653            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
654            &[],
655            &[],
656        );
657        let key2 = RootfsCache::compute_key(
658            "img",
659            &["sha256:bbb".to_string(), "sha256:aaa".to_string()],
660            &[],
661            &[],
662        );
663        // Layer order matters (different filesystem result)
664        assert_ne!(key1, key2);
665    }
666
667    #[test]
668    fn test_compute_key_entrypoint_order_matters() {
669        let key1 =
670            RootfsCache::compute_key("img", &[], &["/bin/sh".to_string(), "-c".to_string()], &[]);
671        let key2 =
672            RootfsCache::compute_key("img", &[], &["-c".to_string(), "/bin/sh".to_string()], &[]);
673        assert_ne!(key1, key2);
674    }
675
676    #[test]
677    fn test_compute_key_with_special_characters() {
678        let key = RootfsCache::compute_key(
679            "registry.example.com/org/image:v1.0-beta+build.123",
680            &["sha256:abc/def".to_string()],
681            &[
682                "/bin/sh".to_string(),
683                "-c".to_string(),
684                "echo 'hello world'".to_string(),
685            ],
686            &[("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())],
687        );
688        assert_eq!(key.len(), 64);
689        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
690    }
691
692    #[test]
693    fn test_compute_key_empty_all_params() {
694        let key = RootfsCache::compute_key("", &[], &[], &[]);
695        assert_eq!(key.len(), 64);
696        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
697    }
698
699    #[test]
700    fn test_rootfs_cache_get_updates_last_accessed() {
701        let tmp = TempDir::new().unwrap();
702        let cache = RootfsCache::new(tmp.path()).unwrap();
703        let key = "access_test";
704
705        let source = tmp.path().join("source");
706        create_test_rootfs(&source, &[("f.txt", "data")]);
707        cache.put(key, &source, "test").unwrap();
708
709        // Read initial metadata
710        let meta_path = tmp.path().join(format!("{}.meta.json", key));
711        let content = std::fs::read_to_string(&meta_path).unwrap();
712        let meta_before: RootfsMeta = serde_json::from_str(&content).unwrap();
713
714        std::thread::sleep(std::time::Duration::from_millis(10));
715
716        // Access the cache entry
717        cache.get(key).unwrap();
718
719        // Read updated metadata
720        let content = std::fs::read_to_string(&meta_path).unwrap();
721        let meta_after: RootfsMeta = serde_json::from_str(&content).unwrap();
722
723        assert!(meta_after.last_accessed >= meta_before.last_accessed);
724        assert_eq!(meta_after.cached_at, meta_before.cached_at);
725    }
726
727    #[test]
728    fn test_rootfs_cache_get_directory_without_metadata() {
729        let tmp = TempDir::new().unwrap();
730        let cache = RootfsCache::new(tmp.path()).unwrap();
731        let key = "no_meta";
732
733        // Create rootfs directory but no metadata file
734        std::fs::create_dir_all(tmp.path().join(key)).unwrap();
735
736        let result = cache.get(key).unwrap();
737        assert!(result.is_none());
738    }
739
740    #[test]
741    fn test_rootfs_cache_get_metadata_without_directory() {
742        let tmp = TempDir::new().unwrap();
743        let cache = RootfsCache::new(tmp.path()).unwrap();
744        let key = "no_dir";
745
746        // Create metadata file but no rootfs directory
747        let meta = RootfsMeta {
748            key: key.to_string(),
749            description: "orphan".to_string(),
750            size_bytes: 0,
751            cached_at: 0,
752            last_accessed: 0,
753        };
754        std::fs::write(
755            tmp.path().join(format!("{}.meta.json", key)),
756            serde_json::to_string(&meta).unwrap(),
757        )
758        .unwrap();
759
760        let result = cache.get(key).unwrap();
761        assert!(result.is_none());
762    }
763
764    #[test]
765    fn test_rootfs_cache_get_corrupted_metadata() {
766        let tmp = TempDir::new().unwrap();
767        let cache = RootfsCache::new(tmp.path()).unwrap();
768        let key = "corrupted";
769
770        // Create rootfs directory and corrupted metadata
771        std::fs::create_dir_all(tmp.path().join(key)).unwrap();
772        std::fs::write(
773            tmp.path().join(format!("{}.meta.json", key)),
774            "not valid json!!!",
775        )
776        .unwrap();
777
778        // Should still return Some (directory + meta file both exist)
779        let result = cache.get(key).unwrap();
780        assert!(result.is_some());
781    }
782
783    #[test]
784    fn test_rootfs_cache_put_source_not_exists() {
785        let tmp = TempDir::new().unwrap();
786        let cache = RootfsCache::new(tmp.path()).unwrap();
787
788        let nonexistent = tmp.path().join("does_not_exist");
789        let result = cache.put("bad_key", &nonexistent, "bad source");
790        assert!(result.is_err());
791    }
792
793    #[test]
794    fn test_rootfs_cache_put_same_key_is_idempotent() {
795        // `put` is only ever called on a cache miss, so an existing entry can
796        // only come from a concurrent miss of the SAME image (identical
797        // content). Re-putting must keep the first entry, not remove-and-recopy
798        // (which corrupts the cache when two builds of the same image race).
799        let tmp = TempDir::new().unwrap();
800        let cache = RootfsCache::new(tmp.path()).unwrap();
801        let key = "idempotent";
802
803        let s1 = tmp.path().join("v1");
804        create_test_rootfs(&s1, &[("v1.txt", "version 1")]);
805        let first = cache.put(key, &s1, "first").unwrap();
806
807        let s2 = tmp.path().join("v2");
808        create_test_rootfs(&s2, &[("v2.txt", "version 2")]);
809        let second = cache.put(key, &s2, "second").unwrap();
810
811        // Same path, first content + metadata preserved (idempotent, no overwrite).
812        assert_eq!(first, second);
813        assert!(second.join("v1.txt").is_file());
814        assert!(!second.join("v2.txt").exists());
815        let meta_path = tmp.path().join(format!("{}.meta.json", key));
816        let meta: RootfsMeta =
817            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
818        assert_eq!(meta.description, "first");
819    }
820
821    #[test]
822    fn test_rootfs_cache_concurrent_put_same_key_no_corruption() {
823        use std::sync::Arc;
824
825        let tmp = TempDir::new().unwrap();
826        let cache = Arc::new(RootfsCache::new(tmp.path()).unwrap());
827        let key = "concurrent";
828        let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
829
830        let handles: Vec<_> = (0..12)
831            .map(|i| {
832                let cache = Arc::clone(&cache);
833                let src = tmp.path().join(format!("src{i}"));
834                create_test_rootfs(&src, files);
835                std::thread::spawn(move || cache.put(key, &src, "race").unwrap())
836            })
837            .collect();
838        let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
839
840        for p in &paths {
841            assert_eq!(p, &paths[0]);
842            assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
843            assert_eq!(
844                std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
845                "beta"
846            );
847        }
848        assert!(cache.get(key).unwrap().is_some());
849    }
850
851    #[test]
852    fn test_rootfs_cache_prune_both_constraints() {
853        let tmp = TempDir::new().unwrap();
854        let cache = RootfsCache::new(tmp.path()).unwrap();
855
856        // Add 5 entries with 100 bytes each
857        for i in 0..5 {
858            let source = tmp.path().join(format!("s{}", i));
859            create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
860            cache
861                .put(&format!("key{}", i), &source, &format!("entry {}", i))
862                .unwrap();
863            std::thread::sleep(std::time::Duration::from_millis(10));
864        }
865
866        // Prune with both constraints: max 3 entries AND max 200 bytes
867        // Both constraints should be satisfied
868        let evicted = cache.prune(3, 200).unwrap();
869        assert!(evicted >= 2);
870        let remaining = cache.entry_count().unwrap();
871        assert!(remaining <= 3);
872    }
873
874    #[test]
875    fn test_rootfs_cache_prune_zero_limits() {
876        let tmp = TempDir::new().unwrap();
877        let cache = RootfsCache::new(tmp.path()).unwrap();
878
879        let source = tmp.path().join("source");
880        create_test_rootfs(&source, &[("f.txt", "data")]);
881        cache.put("k1", &source, "one").unwrap();
882        cache.put("k2", &source, "two").unwrap();
883
884        // Prune with 0 entries limit — should evict all
885        let evicted = cache.prune(0, u64::MAX).unwrap();
886        assert_eq!(evicted, 2);
887        assert_eq!(cache.entry_count().unwrap(), 0);
888    }
889
890    #[test]
891    fn test_rootfs_cache_list_entries_ignores_non_meta_files() {
892        let tmp = TempDir::new().unwrap();
893        let cache = RootfsCache::new(tmp.path()).unwrap();
894
895        // Add a valid entry
896        let source = tmp.path().join("source");
897        create_test_rootfs(&source, &[("f.txt", "data")]);
898        cache.put("valid_key", &source, "valid").unwrap();
899
900        // Add noise files
901        std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
902        std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
903        std::fs::create_dir_all(tmp.path().join("random_dir")).unwrap();
904
905        let entries = cache.list_entries().unwrap();
906        assert_eq!(entries.len(), 1);
907        assert_eq!(entries[0].key, "valid_key");
908    }
909
910    #[test]
911    fn test_rootfs_cache_list_entries_skips_invalid_json() {
912        let tmp = TempDir::new().unwrap();
913        let cache = RootfsCache::new(tmp.path()).unwrap();
914
915        // Add a valid entry
916        let source = tmp.path().join("source");
917        create_test_rootfs(&source, &[("f.txt", "data")]);
918        cache.put("valid_key", &source, "valid").unwrap();
919
920        // Add corrupted .meta.json
921        std::fs::write(tmp.path().join("corrupted.meta.json"), "not json").unwrap();
922
923        let entries = cache.list_entries().unwrap();
924        assert_eq!(entries.len(), 1);
925        assert_eq!(entries[0].key, "valid_key");
926    }
927
928    #[test]
929    fn test_rootfs_cache_put_preserves_content() {
930        let tmp = TempDir::new().unwrap();
931        let cache = RootfsCache::new(tmp.path()).unwrap();
932
933        let source = tmp.path().join("source");
934        create_test_rootfs(
935            &source,
936            &[
937                ("bin/agent", "binary_content"),
938                ("etc/config.json", r#"{"key":"value"}"#),
939                ("lib/deep/nested.so", "shared_object"),
940            ],
941        );
942
943        let cached = cache.put("content_key", &source, "content test").unwrap();
944
945        assert_eq!(
946            std::fs::read_to_string(cached.join("bin/agent")).unwrap(),
947            "binary_content"
948        );
949        assert_eq!(
950            std::fs::read_to_string(cached.join("etc/config.json")).unwrap(),
951            r#"{"key":"value"}"#
952        );
953        assert_eq!(
954            std::fs::read_to_string(cached.join("lib/deep/nested.so")).unwrap(),
955            "shared_object"
956        );
957    }
958
959    #[test]
960    fn test_rootfs_cache_invalidate_then_put_same_key() {
961        let tmp = TempDir::new().unwrap();
962        let cache = RootfsCache::new(tmp.path()).unwrap();
963        let key = "reuse_key";
964
965        let s1 = tmp.path().join("s1");
966        create_test_rootfs(&s1, &[("v1.txt", "first")]);
967        cache.put(key, &s1, "first").unwrap();
968
969        cache.invalidate(key).unwrap();
970        assert!(cache.get(key).unwrap().is_none());
971
972        let s2 = tmp.path().join("s2");
973        create_test_rootfs(&s2, &[("v2.txt", "second")]);
974        let cached = cache.put(key, &s2, "second").unwrap();
975
976        assert!(cache.get(key).unwrap().is_some());
977        assert!(cached.join("v2.txt").is_file());
978        assert!(!cached.join("v1.txt").exists());
979    }
980}