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