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