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