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