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 limit.
194    ///
195    /// Evicts least-recently-accessed entries first.
196    /// Returns the number of entries evicted.
197    pub fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
198        let mut entries = self.list_entries()?;
199
200        if entries.len() <= max_entries {
201            let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
202            if total_size <= max_bytes {
203                return Ok(0);
204            }
205        }
206
207        // Sort by last_accessed ascending (oldest first)
208        entries.sort_by_key(|e| e.last_accessed);
209
210        let mut current_count = entries.len();
211        let mut current_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
212        let mut evicted = 0;
213
214        for entry in &entries {
215            if current_count <= max_entries && current_size <= max_bytes {
216                break;
217            }
218            self.invalidate(&entry.key)?;
219            current_count -= 1;
220            current_size = current_size.saturating_sub(entry.size_bytes);
221            evicted += 1;
222
223            tracing::debug!(
224                key = %entry.key,
225                description = %entry.description,
226                size_bytes = entry.size_bytes,
227                "Evicted cached rootfs"
228            );
229        }
230
231        Ok(evicted)
232    }
233
234    /// List all cached rootfs entries with their metadata.
235    pub fn list_entries(&self) -> Result<Vec<RootfsMeta>> {
236        let mut entries = Vec::new();
237
238        let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
239            BoxError::CacheError(format!(
240                "Failed to read rootfs cache directory {}: {}",
241                self.cache_dir.display(),
242                e
243            ))
244        })?;
245
246        for entry in read_dir {
247            let entry = entry.map_err(|e| {
248                BoxError::CacheError(format!("Failed to read directory entry: {}", e))
249            })?;
250            let path = entry.path();
251
252            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
253                if name.ends_with(".meta.json") {
254                    if let Ok(content) = std::fs::read_to_string(&path) {
255                        if let Ok(meta) = serde_json::from_str::<RootfsMeta>(&content) {
256                            entries.push(meta);
257                        }
258                    }
259                }
260            }
261        }
262
263        Ok(entries)
264    }
265
266    /// Get the total size of all cached rootfs entries in bytes.
267    pub fn total_size(&self) -> Result<u64> {
268        Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
269    }
270
271    /// Get the number of cached rootfs entries.
272    pub fn entry_count(&self) -> Result<usize> {
273        Ok(self.list_entries()?.len())
274    }
275}
276
277impl a3s_box_core::traits::CacheBackend for RootfsCache {
278    fn get(&self, key: &str) -> Result<Option<PathBuf>> {
279        self.get(key)
280    }
281
282    fn put(&self, key: &str, source_dir: &Path, description: &str) -> Result<PathBuf> {
283        self.put(key, source_dir, description)
284    }
285
286    fn invalidate(&self, key: &str) -> Result<()> {
287        self.invalidate(key)
288    }
289
290    fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
291        self.prune(max_entries, max_bytes)
292    }
293
294    fn list(&self) -> Result<Vec<a3s_box_core::traits::CacheEntry>> {
295        self.list_entries().map(|entries| {
296            entries
297                .into_iter()
298                .map(|m| a3s_box_core::traits::CacheEntry {
299                    key: m.key,
300                    description: m.description,
301                    size_bytes: m.size_bytes,
302                    cached_at: m.cached_at,
303                    last_accessed: m.last_accessed,
304                })
305                .collect()
306        })
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use tempfile::TempDir;
314
315    fn create_test_rootfs(dir: &Path, files: &[(&str, &str)]) {
316        std::fs::create_dir_all(dir).unwrap();
317        for (name, content) in files {
318            let file_path = dir.join(name);
319            if let Some(parent) = file_path.parent() {
320                std::fs::create_dir_all(parent).unwrap();
321            }
322            std::fs::write(&file_path, content).unwrap();
323        }
324    }
325
326    #[test]
327    fn test_rootfs_cache_new_creates_directory() {
328        let tmp = TempDir::new().unwrap();
329        let cache_dir = tmp.path().join("rootfs");
330
331        assert!(!cache_dir.exists());
332        let _cache = RootfsCache::new(&cache_dir).unwrap();
333        assert!(cache_dir.is_dir());
334    }
335
336    #[test]
337    fn test_rootfs_cache_get_miss() {
338        let tmp = TempDir::new().unwrap();
339        let cache = RootfsCache::new(tmp.path()).unwrap();
340
341        let result = cache.get("nonexistent_key").unwrap();
342        assert!(result.is_none());
343    }
344
345    #[test]
346    fn test_rootfs_cache_put_and_get() {
347        let tmp = TempDir::new().unwrap();
348        let cache = RootfsCache::new(tmp.path()).unwrap();
349
350        let source = tmp.path().join("source_rootfs");
351        create_test_rootfs(
352            &source,
353            &[("bin/agent", "binary"), ("etc/config.json", "{}")],
354        );
355
356        let key = "abc123def456";
357        let cached_path = cache.put(key, &source, "test rootfs").unwrap();
358
359        assert!(cached_path.is_dir());
360        assert!(cached_path.join("bin/agent").is_file());
361        assert!(cached_path.join("etc/config.json").is_file());
362
363        let result = cache.get(key).unwrap();
364        assert!(result.is_some());
365        assert_eq!(result.unwrap(), cached_path);
366    }
367
368    #[test]
369    fn test_rootfs_cache_invalidate() {
370        let tmp = TempDir::new().unwrap();
371        let cache = RootfsCache::new(tmp.path()).unwrap();
372        let key = "to_invalidate";
373
374        let source = tmp.path().join("source");
375        create_test_rootfs(&source, &[("data.bin", "data")]);
376        cache.put(key, &source, "temp").unwrap();
377
378        assert!(cache.get(key).unwrap().is_some());
379        cache.invalidate(key).unwrap();
380        assert!(cache.get(key).unwrap().is_none());
381    }
382
383    #[test]
384    fn test_rootfs_cache_invalidate_nonexistent() {
385        let tmp = TempDir::new().unwrap();
386        let cache = RootfsCache::new(tmp.path()).unwrap();
387        cache.invalidate("does_not_exist").unwrap();
388    }
389
390    #[test]
391    fn test_rootfs_cache_list_entries() {
392        let tmp = TempDir::new().unwrap();
393        let cache = RootfsCache::new(tmp.path()).unwrap();
394
395        assert_eq!(cache.list_entries().unwrap().len(), 0);
396
397        let s1 = tmp.path().join("s1");
398        create_test_rootfs(&s1, &[("a.txt", "aaa")]);
399        cache.put("key1", &s1, "first").unwrap();
400
401        let s2 = tmp.path().join("s2");
402        create_test_rootfs(&s2, &[("b.txt", "bbb")]);
403        cache.put("key2", &s2, "second").unwrap();
404
405        let entries = cache.list_entries().unwrap();
406        assert_eq!(entries.len(), 2);
407
408        let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
409        assert!(keys.contains(&"key1"));
410        assert!(keys.contains(&"key2"));
411    }
412
413    #[test]
414    fn test_rootfs_cache_entry_count() {
415        let tmp = TempDir::new().unwrap();
416        let cache = RootfsCache::new(tmp.path()).unwrap();
417
418        assert_eq!(cache.entry_count().unwrap(), 0);
419
420        let source = tmp.path().join("source");
421        create_test_rootfs(&source, &[("f.txt", "data")]);
422        cache.put("k1", &source, "one").unwrap();
423        cache.put("k2", &source, "two").unwrap();
424
425        assert_eq!(cache.entry_count().unwrap(), 2);
426    }
427
428    #[test]
429    fn test_rootfs_cache_total_size() {
430        let tmp = TempDir::new().unwrap();
431        let cache = RootfsCache::new(tmp.path()).unwrap();
432
433        assert_eq!(cache.total_size().unwrap(), 0);
434
435        let source = tmp.path().join("source");
436        create_test_rootfs(&source, &[("data.txt", "hello world")]);
437        cache.put("sized", &source, "sized entry").unwrap();
438
439        assert!(cache.total_size().unwrap() > 0);
440    }
441
442    #[test]
443    fn test_rootfs_cache_prune_by_count() {
444        let tmp = TempDir::new().unwrap();
445        let cache = RootfsCache::new(tmp.path()).unwrap();
446
447        // Add 5 entries
448        for i in 0..5 {
449            let source = tmp.path().join(format!("s{}", i));
450            create_test_rootfs(&source, &[("f.txt", "data")]);
451            cache
452                .put(&format!("key{}", i), &source, &format!("entry {}", i))
453                .unwrap();
454            std::thread::sleep(std::time::Duration::from_millis(10));
455        }
456
457        assert_eq!(cache.entry_count().unwrap(), 5);
458
459        // Prune to max 2 entries
460        let evicted = cache.prune(2, u64::MAX).unwrap();
461        assert_eq!(evicted, 3);
462        assert_eq!(cache.entry_count().unwrap(), 2);
463    }
464
465    #[test]
466    fn test_rootfs_cache_prune_by_size() {
467        let tmp = TempDir::new().unwrap();
468        let cache = RootfsCache::new(tmp.path()).unwrap();
469
470        for i in 0..3 {
471            let source = tmp.path().join(format!("s{}", i));
472            create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
473            cache
474                .put(&format!("key{}", i), &source, &format!("entry {}", i))
475                .unwrap();
476            std::thread::sleep(std::time::Duration::from_millis(10));
477        }
478
479        // Prune to 1 byte — should evict all but possibly one
480        let evicted = cache.prune(usize::MAX, 1).unwrap();
481        assert!(evicted >= 2);
482    }
483
484    #[test]
485    fn test_rootfs_cache_prune_no_eviction_needed() {
486        let tmp = TempDir::new().unwrap();
487        let cache = RootfsCache::new(tmp.path()).unwrap();
488
489        let source = tmp.path().join("source");
490        create_test_rootfs(&source, &[("f.txt", "data")]);
491        cache.put("key1", &source, "entry").unwrap();
492
493        let evicted = cache.prune(10, u64::MAX).unwrap();
494        assert_eq!(evicted, 0);
495        assert_eq!(cache.entry_count().unwrap(), 1);
496    }
497
498    #[test]
499    fn test_rootfs_cache_metadata_persists() {
500        let tmp = TempDir::new().unwrap();
501        let cache = RootfsCache::new(tmp.path()).unwrap();
502        let key = "meta_test";
503
504        let source = tmp.path().join("source");
505        create_test_rootfs(&source, &[("file.txt", "content")]);
506        cache.put(key, &source, "test description").unwrap();
507
508        let meta_path = tmp.path().join(format!("{}.meta.json", key));
509        assert!(meta_path.is_file());
510
511        let content = std::fs::read_to_string(&meta_path).unwrap();
512        let meta: RootfsMeta = serde_json::from_str(&content).unwrap();
513
514        assert_eq!(meta.key, key);
515        assert_eq!(meta.description, "test description");
516        assert!(meta.size_bytes > 0);
517        assert!(meta.cached_at > 0);
518        assert_eq!(meta.cached_at, meta.last_accessed);
519    }
520
521    #[test]
522    fn test_compute_key_deterministic() {
523        let key1 = RootfsCache::compute_key(
524            "nginx:latest",
525            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
526            &["/bin/nginx".to_string()],
527            &[("PATH".to_string(), "/usr/bin".to_string())],
528        );
529        let key2 = RootfsCache::compute_key(
530            "nginx:latest",
531            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
532            &["/bin/nginx".to_string()],
533            &[("PATH".to_string(), "/usr/bin".to_string())],
534        );
535        assert_eq!(key1, key2);
536    }
537
538    #[test]
539    fn test_compute_key_different_inputs() {
540        let key1 = RootfsCache::compute_key("nginx:latest", &[], &[], &[]);
541        let key2 = RootfsCache::compute_key("nginx:1.25", &[], &[], &[]);
542        assert_ne!(key1, key2);
543    }
544
545    #[test]
546    fn test_compute_key_env_order_independent() {
547        let key1 = RootfsCache::compute_key(
548            "img",
549            &[],
550            &[],
551            &[
552                ("A".to_string(), "1".to_string()),
553                ("B".to_string(), "2".to_string()),
554            ],
555        );
556        let key2 = RootfsCache::compute_key(
557            "img",
558            &[],
559            &[],
560            &[
561                ("B".to_string(), "2".to_string()),
562                ("A".to_string(), "1".to_string()),
563            ],
564        );
565        assert_eq!(key1, key2);
566    }
567
568    #[test]
569    fn test_compute_key_is_hex_sha256() {
570        let key = RootfsCache::compute_key("test", &[], &[], &[]);
571        // SHA256 hex is 64 characters
572        assert_eq!(key.len(), 64);
573        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
574    }
575
576    #[test]
577    fn test_compute_key_layer_order_matters() {
578        let key1 = RootfsCache::compute_key(
579            "img",
580            &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
581            &[],
582            &[],
583        );
584        let key2 = RootfsCache::compute_key(
585            "img",
586            &["sha256:bbb".to_string(), "sha256:aaa".to_string()],
587            &[],
588            &[],
589        );
590        // Layer order matters (different filesystem result)
591        assert_ne!(key1, key2);
592    }
593
594    #[test]
595    fn test_compute_key_entrypoint_order_matters() {
596        let key1 =
597            RootfsCache::compute_key("img", &[], &["/bin/sh".to_string(), "-c".to_string()], &[]);
598        let key2 =
599            RootfsCache::compute_key("img", &[], &["-c".to_string(), "/bin/sh".to_string()], &[]);
600        assert_ne!(key1, key2);
601    }
602
603    #[test]
604    fn test_compute_key_with_special_characters() {
605        let key = RootfsCache::compute_key(
606            "registry.example.com/org/image:v1.0-beta+build.123",
607            &["sha256:abc/def".to_string()],
608            &[
609                "/bin/sh".to_string(),
610                "-c".to_string(),
611                "echo 'hello world'".to_string(),
612            ],
613            &[("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())],
614        );
615        assert_eq!(key.len(), 64);
616        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
617    }
618
619    #[test]
620    fn test_compute_key_empty_all_params() {
621        let key = RootfsCache::compute_key("", &[], &[], &[]);
622        assert_eq!(key.len(), 64);
623        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
624    }
625
626    #[test]
627    fn test_rootfs_cache_get_updates_last_accessed() {
628        let tmp = TempDir::new().unwrap();
629        let cache = RootfsCache::new(tmp.path()).unwrap();
630        let key = "access_test";
631
632        let source = tmp.path().join("source");
633        create_test_rootfs(&source, &[("f.txt", "data")]);
634        cache.put(key, &source, "test").unwrap();
635
636        // Read initial metadata
637        let meta_path = tmp.path().join(format!("{}.meta.json", key));
638        let content = std::fs::read_to_string(&meta_path).unwrap();
639        let meta_before: RootfsMeta = serde_json::from_str(&content).unwrap();
640
641        std::thread::sleep(std::time::Duration::from_millis(10));
642
643        // Access the cache entry
644        cache.get(key).unwrap();
645
646        // Read updated metadata
647        let content = std::fs::read_to_string(&meta_path).unwrap();
648        let meta_after: RootfsMeta = serde_json::from_str(&content).unwrap();
649
650        assert!(meta_after.last_accessed >= meta_before.last_accessed);
651        assert_eq!(meta_after.cached_at, meta_before.cached_at);
652    }
653
654    #[test]
655    fn test_rootfs_cache_get_directory_without_metadata() {
656        let tmp = TempDir::new().unwrap();
657        let cache = RootfsCache::new(tmp.path()).unwrap();
658        let key = "no_meta";
659
660        // Create rootfs directory but no metadata file
661        std::fs::create_dir_all(tmp.path().join(key)).unwrap();
662
663        let result = cache.get(key).unwrap();
664        assert!(result.is_none());
665    }
666
667    #[test]
668    fn test_rootfs_cache_get_metadata_without_directory() {
669        let tmp = TempDir::new().unwrap();
670        let cache = RootfsCache::new(tmp.path()).unwrap();
671        let key = "no_dir";
672
673        // Create metadata file but no rootfs directory
674        let meta = RootfsMeta {
675            key: key.to_string(),
676            description: "orphan".to_string(),
677            size_bytes: 0,
678            cached_at: 0,
679            last_accessed: 0,
680        };
681        std::fs::write(
682            tmp.path().join(format!("{}.meta.json", key)),
683            serde_json::to_string(&meta).unwrap(),
684        )
685        .unwrap();
686
687        let result = cache.get(key).unwrap();
688        assert!(result.is_none());
689    }
690
691    #[test]
692    fn test_rootfs_cache_get_corrupted_metadata() {
693        let tmp = TempDir::new().unwrap();
694        let cache = RootfsCache::new(tmp.path()).unwrap();
695        let key = "corrupted";
696
697        // Create rootfs directory and corrupted metadata
698        std::fs::create_dir_all(tmp.path().join(key)).unwrap();
699        std::fs::write(
700            tmp.path().join(format!("{}.meta.json", key)),
701            "not valid json!!!",
702        )
703        .unwrap();
704
705        // Should still return Some (directory + meta file both exist)
706        let result = cache.get(key).unwrap();
707        assert!(result.is_some());
708    }
709
710    #[test]
711    fn test_rootfs_cache_put_source_not_exists() {
712        let tmp = TempDir::new().unwrap();
713        let cache = RootfsCache::new(tmp.path()).unwrap();
714
715        let nonexistent = tmp.path().join("does_not_exist");
716        let result = cache.put("bad_key", &nonexistent, "bad source");
717        assert!(result.is_err());
718    }
719
720    #[test]
721    fn test_rootfs_cache_put_same_key_is_idempotent() {
722        // `put` is only ever called on a cache miss, so an existing entry can
723        // only come from a concurrent miss of the SAME image (identical
724        // content). Re-putting must keep the first entry, not remove-and-recopy
725        // (which corrupts the cache when two builds of the same image race).
726        let tmp = TempDir::new().unwrap();
727        let cache = RootfsCache::new(tmp.path()).unwrap();
728        let key = "idempotent";
729
730        let s1 = tmp.path().join("v1");
731        create_test_rootfs(&s1, &[("v1.txt", "version 1")]);
732        let first = cache.put(key, &s1, "first").unwrap();
733
734        let s2 = tmp.path().join("v2");
735        create_test_rootfs(&s2, &[("v2.txt", "version 2")]);
736        let second = cache.put(key, &s2, "second").unwrap();
737
738        // Same path, first content + metadata preserved (idempotent, no overwrite).
739        assert_eq!(first, second);
740        assert!(second.join("v1.txt").is_file());
741        assert!(!second.join("v2.txt").exists());
742        let meta_path = tmp.path().join(format!("{}.meta.json", key));
743        let meta: RootfsMeta =
744            serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
745        assert_eq!(meta.description, "first");
746    }
747
748    #[test]
749    fn test_rootfs_cache_concurrent_put_same_key_no_corruption() {
750        use std::sync::Arc;
751
752        let tmp = TempDir::new().unwrap();
753        let cache = Arc::new(RootfsCache::new(tmp.path()).unwrap());
754        let key = "concurrent";
755        let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
756
757        let handles: Vec<_> = (0..12)
758            .map(|i| {
759                let cache = Arc::clone(&cache);
760                let src = tmp.path().join(format!("src{i}"));
761                create_test_rootfs(&src, files);
762                std::thread::spawn(move || cache.put(key, &src, "race").unwrap())
763            })
764            .collect();
765        let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
766
767        for p in &paths {
768            assert_eq!(p, &paths[0]);
769            assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
770            assert_eq!(
771                std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
772                "beta"
773            );
774        }
775        assert!(cache.get(key).unwrap().is_some());
776    }
777
778    #[test]
779    fn test_rootfs_cache_prune_both_constraints() {
780        let tmp = TempDir::new().unwrap();
781        let cache = RootfsCache::new(tmp.path()).unwrap();
782
783        // Add 5 entries with 100 bytes each
784        for i in 0..5 {
785            let source = tmp.path().join(format!("s{}", i));
786            create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
787            cache
788                .put(&format!("key{}", i), &source, &format!("entry {}", i))
789                .unwrap();
790            std::thread::sleep(std::time::Duration::from_millis(10));
791        }
792
793        // Prune with both constraints: max 3 entries AND max 200 bytes
794        // Both constraints should be satisfied
795        let evicted = cache.prune(3, 200).unwrap();
796        assert!(evicted >= 2);
797        let remaining = cache.entry_count().unwrap();
798        assert!(remaining <= 3);
799    }
800
801    #[test]
802    fn test_rootfs_cache_prune_zero_limits() {
803        let tmp = TempDir::new().unwrap();
804        let cache = RootfsCache::new(tmp.path()).unwrap();
805
806        let source = tmp.path().join("source");
807        create_test_rootfs(&source, &[("f.txt", "data")]);
808        cache.put("k1", &source, "one").unwrap();
809        cache.put("k2", &source, "two").unwrap();
810
811        // Prune with 0 entries limit — should evict all
812        let evicted = cache.prune(0, u64::MAX).unwrap();
813        assert_eq!(evicted, 2);
814        assert_eq!(cache.entry_count().unwrap(), 0);
815    }
816
817    #[test]
818    fn test_rootfs_cache_list_entries_ignores_non_meta_files() {
819        let tmp = TempDir::new().unwrap();
820        let cache = RootfsCache::new(tmp.path()).unwrap();
821
822        // Add a valid entry
823        let source = tmp.path().join("source");
824        create_test_rootfs(&source, &[("f.txt", "data")]);
825        cache.put("valid_key", &source, "valid").unwrap();
826
827        // Add noise files
828        std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
829        std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
830        std::fs::create_dir_all(tmp.path().join("random_dir")).unwrap();
831
832        let entries = cache.list_entries().unwrap();
833        assert_eq!(entries.len(), 1);
834        assert_eq!(entries[0].key, "valid_key");
835    }
836
837    #[test]
838    fn test_rootfs_cache_list_entries_skips_invalid_json() {
839        let tmp = TempDir::new().unwrap();
840        let cache = RootfsCache::new(tmp.path()).unwrap();
841
842        // Add a valid entry
843        let source = tmp.path().join("source");
844        create_test_rootfs(&source, &[("f.txt", "data")]);
845        cache.put("valid_key", &source, "valid").unwrap();
846
847        // Add corrupted .meta.json
848        std::fs::write(tmp.path().join("corrupted.meta.json"), "not json").unwrap();
849
850        let entries = cache.list_entries().unwrap();
851        assert_eq!(entries.len(), 1);
852        assert_eq!(entries[0].key, "valid_key");
853    }
854
855    #[test]
856    fn test_rootfs_cache_put_preserves_content() {
857        let tmp = TempDir::new().unwrap();
858        let cache = RootfsCache::new(tmp.path()).unwrap();
859
860        let source = tmp.path().join("source");
861        create_test_rootfs(
862            &source,
863            &[
864                ("bin/agent", "binary_content"),
865                ("etc/config.json", r#"{"key":"value"}"#),
866                ("lib/deep/nested.so", "shared_object"),
867            ],
868        );
869
870        let cached = cache.put("content_key", &source, "content test").unwrap();
871
872        assert_eq!(
873            std::fs::read_to_string(cached.join("bin/agent")).unwrap(),
874            "binary_content"
875        );
876        assert_eq!(
877            std::fs::read_to_string(cached.join("etc/config.json")).unwrap(),
878            r#"{"key":"value"}"#
879        );
880        assert_eq!(
881            std::fs::read_to_string(cached.join("lib/deep/nested.so")).unwrap(),
882            "shared_object"
883        );
884    }
885
886    #[test]
887    fn test_rootfs_cache_invalidate_then_put_same_key() {
888        let tmp = TempDir::new().unwrap();
889        let cache = RootfsCache::new(tmp.path()).unwrap();
890        let key = "reuse_key";
891
892        let s1 = tmp.path().join("s1");
893        create_test_rootfs(&s1, &[("v1.txt", "first")]);
894        cache.put(key, &s1, "first").unwrap();
895
896        cache.invalidate(key).unwrap();
897        assert!(cache.get(key).unwrap().is_none());
898
899        let s2 = tmp.path().join("s2");
900        create_test_rootfs(&s2, &[("v2.txt", "second")]);
901        let cached = cache.put(key, &s2, "second").unwrap();
902
903        assert!(cache.get(key).unwrap().is_some());
904        assert!(cached.join("v2.txt").is_file());
905        assert!(!cached.join("v1.txt").exists());
906    }
907}