Skip to main content

a3s_box_runtime/cache/
layer_cache.rs

1//! Content-addressed cache for extracted OCI layers.
2//!
3//! Each layer is stored by its digest (SHA256), so identical layers
4//! shared across different images are only stored once on disk.
5
6use std::path::{Path, PathBuf};
7
8use a3s_box_core::error::{BoxError, Result};
9use a3s_box_core::traits::{CacheBackend, CacheEntry};
10use serde::{Deserialize, Serialize};
11
12/// Metadata for a cached layer entry.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LayerMeta {
15    /// Layer digest (e.g., "sha256:abc123...")
16    pub digest: String,
17    /// Size of the extracted layer in bytes
18    pub size_bytes: u64,
19    /// When this layer was cached (Unix timestamp)
20    pub cached_at: i64,
21    /// Last time this layer was accessed (Unix timestamp)
22    pub last_accessed: i64,
23}
24
25/// Content-addressed cache for extracted OCI layers.
26///
27/// Layers are stored by digest under `cache_dir/layers/<digest>/`.
28/// Metadata is stored alongside as `<digest>.meta.json`.
29pub struct LayerCache {
30    /// Root directory for layer cache (e.g., ~/.a3s/cache/layers)
31    cache_dir: PathBuf,
32}
33
34impl LayerCache {
35    /// Create a new layer cache at the given directory.
36    pub fn new(cache_dir: &Path) -> Result<Self> {
37        std::fs::create_dir_all(cache_dir).map_err(|e| {
38            BoxError::CacheError(format!(
39                "Failed to create layer cache directory {}: {}",
40                cache_dir.display(),
41                e
42            ))
43        })?;
44
45        Ok(Self {
46            cache_dir: cache_dir.to_path_buf(),
47        })
48    }
49
50    /// Get the path to a cached layer by digest.
51    ///
52    /// Returns `None` if the layer is not cached or the cache entry is invalid.
53    pub fn get(&self, digest: &str) -> Result<Option<PathBuf>> {
54        let safe_name = Self::digest_to_dirname(digest);
55        let layer_dir = self.cache_dir.join(&safe_name);
56        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
57
58        if !layer_dir.is_dir() || !meta_path.is_file() {
59            return Ok(None);
60        }
61
62        // Update last_accessed timestamp
63        if let Ok(content) = std::fs::read_to_string(&meta_path) {
64            if let Ok(mut meta) = serde_json::from_str::<LayerMeta>(&content) {
65                meta.last_accessed = chrono::Utc::now().timestamp();
66                if let Err(e) = std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?) {
67                    tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update layer cache metadata");
68                }
69            }
70        }
71
72        Ok(Some(layer_dir))
73    }
74
75    /// Store an extracted layer directory in the cache.
76    ///
77    /// Copies the contents of `source_dir` into the cache keyed by `digest`.
78    /// Returns the path to the cached layer directory.
79    pub fn put(&self, digest: &str, source_dir: &Path) -> Result<PathBuf> {
80        let safe_name = Self::digest_to_dirname(digest);
81        let layer_dir = self.cache_dir.join(&safe_name);
82        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
83
84        // Already fully cached (content-addressed ⇒ identical): nothing to do.
85        // Returning early also makes concurrent puts of the same layer idempotent.
86        if layer_dir.is_dir() && meta_path.is_file() {
87            return Ok(layer_dir);
88        }
89
90        // Atomically publish the extracted layer (staging dir + rename) so a
91        // concurrent pull of the same layer cannot corrupt the cache by
92        // removing/interleaving a half-copied directory.
93        publish_dir_atomically(source_dir, &layer_dir, &self.cache_dir)?;
94
95        // Calculate size (from whichever copy landed — they are identical).
96        let size_bytes = dir_size(&layer_dir).unwrap_or(0);
97
98        // Write metadata atomically (unique temp + rename).
99        let now = chrono::Utc::now().timestamp();
100        let meta = LayerMeta {
101            digest: digest.to_string(),
102            size_bytes,
103            cached_at: now,
104            last_accessed: now,
105        };
106        write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)?;
107
108        tracing::debug!(
109            digest = %digest,
110            size_bytes,
111            path = %layer_dir.display(),
112            "Cached OCI layer"
113        );
114
115        Ok(layer_dir)
116    }
117
118    /// Remove a cached layer by digest.
119    pub fn invalidate(&self, digest: &str) -> Result<()> {
120        let safe_name = Self::digest_to_dirname(digest);
121        let layer_dir = self.cache_dir.join(&safe_name);
122        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
123
124        if layer_dir.exists() {
125            std::fs::remove_dir_all(&layer_dir).map_err(|e| {
126                BoxError::CacheError(format!(
127                    "Failed to remove cached layer {}: {}",
128                    layer_dir.display(),
129                    e
130                ))
131            })?;
132        }
133        if meta_path.exists() {
134            std::fs::remove_file(&meta_path).map_err(|e| {
135                BoxError::CacheError(format!(
136                    "Failed to remove layer metadata {}: {}",
137                    meta_path.display(),
138                    e
139                ))
140            })?;
141        }
142
143        Ok(())
144    }
145
146    /// Prune the cache to stay within the given byte limit.
147    ///
148    /// Evicts least-recently-accessed entries first.
149    /// Returns the number of entries evicted.
150    pub fn prune(&self, max_bytes: u64) -> Result<usize> {
151        let mut entries = self.list_entries()?;
152
153        // Calculate total size
154        let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
155        if total_size <= max_bytes {
156            return Ok(0);
157        }
158
159        // Sort by last_accessed ascending (oldest first)
160        entries.sort_by_key(|e| e.last_accessed);
161
162        let mut current_size = total_size;
163        let mut evicted = 0;
164
165        for entry in &entries {
166            if current_size <= max_bytes {
167                break;
168            }
169            self.invalidate(&entry.digest)?;
170            current_size = current_size.saturating_sub(entry.size_bytes);
171            evicted += 1;
172
173            tracing::debug!(
174                digest = %entry.digest,
175                size_bytes = entry.size_bytes,
176                "Evicted cached layer"
177            );
178        }
179
180        Ok(evicted)
181    }
182
183    /// List all cached layer entries with their metadata.
184    pub fn list_entries(&self) -> Result<Vec<LayerMeta>> {
185        let mut entries = Vec::new();
186
187        let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
188            BoxError::CacheError(format!(
189                "Failed to read cache directory {}: {}",
190                self.cache_dir.display(),
191                e
192            ))
193        })?;
194
195        for entry in read_dir {
196            let entry = entry.map_err(|e| {
197                BoxError::CacheError(format!("Failed to read directory entry: {}", e))
198            })?;
199            let path = entry.path();
200
201            // Only process .meta.json files
202            if path.extension().and_then(|e| e.to_str()) == Some("json") {
203                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
204                    if name.ends_with(".meta.json") {
205                        if let Ok(content) = std::fs::read_to_string(&path) {
206                            if let Ok(meta) = serde_json::from_str::<LayerMeta>(&content) {
207                                entries.push(meta);
208                            }
209                        }
210                    }
211                }
212            }
213        }
214
215        Ok(entries)
216    }
217
218    /// Get the total size of all cached layers in bytes.
219    pub fn total_size(&self) -> Result<u64> {
220        Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
221    }
222
223    /// Convert a digest string to a safe directory name.
224    ///
225    /// Replaces ':' with '_' to avoid filesystem issues.
226    /// e.g., "sha256:abc123" → "sha256_abc123"
227    fn digest_to_dirname(digest: &str) -> String {
228        digest.replace(':', "_")
229    }
230}
231
232/// Recursively copy a directory and its contents.
233/// Copy `src`'s uid/gid onto `dst` (no symlink follow), best-effort, root only.
234///
235/// `std::fs::copy`/`create_dir_all` do not carry ownership, so a rootfs copied
236/// from extracted layers would collapse to root and lose `COPY --chown` (and
237/// base-image) ownership. Only root can chown to arbitrary ids, so this is a
238/// no-op otherwise.
239#[cfg(unix)]
240fn preserve_owner(meta: &std::fs::Metadata, dst: &Path) {
241    use std::os::unix::ffi::OsStrExt;
242    use std::os::unix::fs::MetadataExt;
243    if unsafe { libc::geteuid() } != 0 {
244        return;
245    }
246    if let Ok(c_path) = std::ffi::CString::new(dst.as_os_str().as_bytes()) {
247        // lchown so a symlink's own ownership is set, not its target's.
248        unsafe {
249            libc::lchown(c_path.as_ptr(), meta.uid(), meta.gid());
250        }
251    }
252}
253
254#[cfg(not(unix))]
255fn preserve_owner(_meta: &std::fs::Metadata, _dst: &Path) {}
256
257pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
258    std::fs::create_dir_all(dst).map_err(|e| {
259        BoxError::CacheError(format!(
260            "Failed to create directory {}: {}",
261            dst.display(),
262            e
263        ))
264    })?;
265    // Mirror the source directory's ownership onto the destination (root only).
266    if let Ok(src_meta) = std::fs::symlink_metadata(src) {
267        preserve_owner(&src_meta, dst);
268    }
269
270    for entry in std::fs::read_dir(src).map_err(|e| {
271        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
272    })? {
273        let entry = entry
274            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
275        let src_path = entry.path();
276        let dst_path = dst.join(entry.file_name());
277
278        // Use symlink_metadata so is_symlink() works correctly (does not follow links).
279        let meta = entry.metadata().map_err(|e| {
280            BoxError::CacheError(format!(
281                "Failed to read metadata for {}: {}",
282                src_path.display(),
283                e
284            ))
285        })?;
286
287        if meta.is_symlink() {
288            #[cfg(unix)]
289            {
290                let target = std::fs::read_link(&src_path).map_err(|e| {
291                    BoxError::CacheError(format!(
292                        "Failed to read symlink {}: {}",
293                        src_path.display(),
294                        e
295                    ))
296                })?;
297                std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
298                    BoxError::CacheError(format!(
299                        "Failed to create symlink {} -> {}: {}",
300                        dst_path.display(),
301                        target.display(),
302                        e
303                    ))
304                })?;
305                preserve_owner(&meta, &dst_path);
306            }
307            #[cfg(not(unix))]
308            {
309                return Err(BoxError::CacheError(format!(
310                    "Symlink copy is not supported on this platform: {}",
311                    src_path.display()
312                )));
313            }
314        } else if meta.is_dir() {
315            copy_dir_recursive(&src_path, &dst_path)?;
316        } else {
317            copy_file_cow(&src_path, &dst_path).map_err(|e| {
318                BoxError::CacheError(format!(
319                    "Failed to copy {} to {}: {}",
320                    src_path.display(),
321                    dst_path.display(),
322                    e
323                ))
324            })?;
325            preserve_owner(&meta, &dst_path);
326        }
327    }
328
329    Ok(())
330}
331
332/// Copy a regular file, preferring a copy-on-write reflink (`FICLONE`) so a new
333/// box's rootfs shares blocks with the cached image — instant, no extra disk — on
334/// reflink-capable filesystems (btrfs, XFS `reflink=1`, bcachefs). Falls back to a
335/// plain byte copy when reflink is unsupported (e.g. ext4) or the source and
336/// destination are on different filesystems. Overlay is preferred on Linux, so
337/// this only runs on the `CopyProvider` fallback path.
338fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
339    #[cfg(target_os = "linux")]
340    {
341        use std::os::unix::io::AsRawFd;
342        // FICLONE = _IOW(0x94, 9, int)
343        const FICLONE: libc::c_ulong = 0x4004_9409;
344        let reflinked = (|| -> std::io::Result<bool> {
345            let s = std::fs::File::open(src)?;
346            let d = std::fs::OpenOptions::new()
347                .write(true)
348                .create(true)
349                .truncate(true)
350                .open(dst)?;
351            // SAFETY: FICLONE's argument is the source fd; both fds are valid for
352            // the call. A non-zero return (unsupported FS / cross-device) just
353            // means "fall back to a byte copy".
354            let rc = unsafe { libc::ioctl(d.as_raw_fd(), FICLONE, s.as_raw_fd()) };
355            if rc != 0 {
356                return Ok(false);
357            }
358            // FICLONE clones data only — copy the permission bits like fs::copy.
359            if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
360                let _ = d.set_permissions(perm);
361            }
362            Ok(true)
363        })()
364        .unwrap_or(false);
365        if reflinked {
366            return Ok(());
367        }
368    }
369    std::fs::copy(src, dst).map(|_| ())
370}
371
372/// Atomically publish `source_dir`'s contents as the content-addressed cache
373/// entry `dest_dir`. Returns `true` if THIS call created the entry, `false` if
374/// an entry was already present (a concurrent put of the same key won).
375///
376/// Copying straight into `dest_dir` (and `remove_dir_all`-ing a pre-existing
377/// one) corrupts the cache when two processes pull the same layer at once: one
378/// deletes the other's half-copied directory, or both interleave files into the
379/// same path. Instead, copy into a unique staging dir under `staging_parent`,
380/// then `rename` it into place. Because both writers use the same key (a
381/// content digest), an entry that already exists is byte-identical, so a lost
382/// rename race is harmless — we keep the winner and drop our staging copy. The
383/// rename is atomic on the same filesystem, so `dest_dir` is only ever absent or
384/// fully populated, never partial.
385pub(crate) fn publish_dir_atomically(
386    source_dir: &Path,
387    dest_dir: &Path,
388    staging_parent: &Path,
389) -> Result<bool> {
390    if dest_dir.exists() {
391        return Ok(false);
392    }
393    let staging = tempfile::Builder::new()
394        .prefix(".staging-")
395        .tempdir_in(staging_parent)
396        .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
397    // copy_dir_recursive create_dir_all's its destination, so copy into a fresh
398    // subpath of the (already-existing) staging dir, then rename that subpath.
399    let staged = staging.path().join("d");
400    copy_dir_recursive(source_dir, &staged)?;
401
402    match std::fs::rename(&staged, dest_dir) {
403        Ok(()) => Ok(true),
404        // Lost the race: a concurrent put populated dest_dir first. Same key ⇒
405        // identical content, so keep the winner (staging auto-removes on drop).
406        Err(_) if dest_dir.exists() => Ok(false),
407        Err(e) => Err(BoxError::CacheError(format!(
408            "Failed to publish cache entry {}: {e}",
409            dest_dir.display()
410        ))),
411    }
412}
413
414/// Atomically write `json` to `meta_path` (unique temp + rename), so a
415/// concurrent reader never sees a half-written metadata file.
416pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
417    let parent = meta_path.parent().ok_or_else(|| {
418        BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
419    })?;
420    let mut tmp = tempfile::NamedTempFile::new_in(parent)
421        .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
422    use std::io::Write as _;
423    tmp.write_all(json.as_bytes())
424        .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
425    tmp.persist(meta_path)
426        .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
427    Ok(())
428}
429
430/// Calculate the total size of a directory recursively.
431pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
432    let mut total = 0;
433    if path.is_dir() {
434        for entry in std::fs::read_dir(path)? {
435            let entry = entry?;
436            let path = entry.path();
437            if path.is_dir() {
438                total += dir_size(&path)?;
439            } else {
440                total += entry.metadata()?.len();
441            }
442        }
443    }
444    Ok(total)
445}
446
447impl CacheBackend for LayerCache {
448    fn get(&self, key: &str) -> Result<Option<PathBuf>> {
449        self.get(key)
450    }
451
452    fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
453        self.put(key, source_dir)
454    }
455
456    fn invalidate(&self, key: &str) -> Result<()> {
457        self.invalidate(key)
458    }
459
460    fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
461        self.prune(max_bytes)
462    }
463
464    fn list(&self) -> Result<Vec<CacheEntry>> {
465        self.list_entries().map(|entries| {
466            entries
467                .into_iter()
468                .map(|m| CacheEntry {
469                    key: m.digest,
470                    description: String::new(),
471                    size_bytes: m.size_bytes,
472                    cached_at: m.cached_at,
473                    last_accessed: m.last_accessed,
474                })
475                .collect()
476        })
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use tempfile::TempDir;
484
485    fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
486        std::fs::create_dir_all(dir).unwrap();
487        for (name, content) in files {
488            let file_path = dir.join(name);
489            if let Some(parent) = file_path.parent() {
490                std::fs::create_dir_all(parent).unwrap();
491            }
492            std::fs::write(&file_path, content).unwrap();
493        }
494    }
495
496    #[test]
497    fn test_layer_cache_new_creates_directory() {
498        let tmp = TempDir::new().unwrap();
499        let cache_dir = tmp.path().join("layers");
500
501        assert!(!cache_dir.exists());
502        let _cache = LayerCache::new(&cache_dir).unwrap();
503        assert!(cache_dir.is_dir());
504    }
505
506    #[test]
507    fn test_layer_cache_get_miss() {
508        let tmp = TempDir::new().unwrap();
509        let cache = LayerCache::new(tmp.path()).unwrap();
510
511        let result = cache.get("sha256:nonexistent").unwrap();
512        assert!(result.is_none());
513    }
514
515    #[test]
516    fn test_layer_cache_put_and_get() {
517        let tmp = TempDir::new().unwrap();
518        let cache = LayerCache::new(tmp.path()).unwrap();
519
520        // Create a source layer directory
521        let source = tmp.path().join("source_layer");
522        create_test_layer(
523            &source,
524            &[("file.txt", "hello"), ("sub/nested.txt", "world")],
525        );
526
527        // Put into cache
528        let digest = "sha256:abc123def456";
529        let cached_path = cache.put(digest, &source).unwrap();
530
531        assert!(cached_path.is_dir());
532        assert!(cached_path.join("file.txt").is_file());
533        assert!(cached_path.join("sub/nested.txt").is_file());
534
535        // Get from cache
536        let result = cache.get(digest).unwrap();
537        assert!(result.is_some());
538        assert_eq!(result.unwrap(), cached_path);
539    }
540
541    #[test]
542    fn test_layer_cache_put_same_digest_is_idempotent() {
543        // A layer digest IS the hash of its content, so the same digest can only
544        // ever map to identical content — re-putting it must be a no-op that
545        // keeps the first entry, not a remove-and-recopy (which corrupts the
546        // cache when two pulls of the same layer race). The "different content"
547        // below is an impossible-in-reality stand-in to prove the first write wins.
548        let tmp = TempDir::new().unwrap();
549        let cache = LayerCache::new(tmp.path()).unwrap();
550        let digest = "sha256:idempotent_test";
551
552        let source1 = tmp.path().join("v1");
553        create_test_layer(&source1, &[("v1.txt", "version 1")]);
554        let first = cache.put(digest, &source1).unwrap();
555
556        let source2 = tmp.path().join("v2");
557        create_test_layer(&source2, &[("v2.txt", "version 2")]);
558        let second = cache.put(digest, &source2).unwrap();
559
560        // Same cache path, first content preserved (idempotent, no overwrite).
561        assert_eq!(first, second);
562        assert!(second.join("v1.txt").is_file());
563        assert!(!second.join("v2.txt").exists());
564    }
565
566    #[test]
567    fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
568        use std::sync::Arc;
569
570        let tmp = TempDir::new().unwrap();
571        let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
572        let digest = "sha256:concurrent_test";
573
574        // Several identical source layers (like the same layer extracted by N
575        // racing pulls), each with the same multi-file content.
576        let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
577        let handles: Vec<_> = (0..12)
578            .map(|i| {
579                let cache = Arc::clone(&cache);
580                let src = tmp.path().join(format!("src{i}"));
581                create_test_layer(&src, files);
582                std::thread::spawn(move || cache.put(digest, &src).unwrap())
583            })
584            .collect();
585        let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
586
587        // Every put returns the same cache dir, and it is COMPLETE (no half-copied
588        // / interleaved layer): all files present with the right content.
589        for p in &paths {
590            assert_eq!(p, &paths[0]);
591            assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
592            assert_eq!(
593                std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
594                "beta"
595            );
596        }
597        assert!(cache.get(digest).unwrap().is_some());
598    }
599
600    #[test]
601    fn test_layer_cache_invalidate() {
602        let tmp = TempDir::new().unwrap();
603        let cache = LayerCache::new(tmp.path()).unwrap();
604        let digest = "sha256:to_invalidate";
605
606        let source = tmp.path().join("source");
607        create_test_layer(&source, &[("data.bin", "binary data")]);
608        cache.put(digest, &source).unwrap();
609
610        // Verify it exists
611        assert!(cache.get(digest).unwrap().is_some());
612
613        // Invalidate
614        cache.invalidate(digest).unwrap();
615
616        // Should be gone
617        assert!(cache.get(digest).unwrap().is_none());
618    }
619
620    #[test]
621    fn test_layer_cache_invalidate_nonexistent() {
622        let tmp = TempDir::new().unwrap();
623        let cache = LayerCache::new(tmp.path()).unwrap();
624
625        // Should not error on nonexistent digest
626        cache.invalidate("sha256:does_not_exist").unwrap();
627    }
628
629    #[test]
630    fn test_layer_cache_list_entries() {
631        let tmp = TempDir::new().unwrap();
632        let cache = LayerCache::new(tmp.path()).unwrap();
633
634        // Empty cache
635        assert_eq!(cache.list_entries().unwrap().len(), 0);
636
637        // Add two layers
638        let s1 = tmp.path().join("s1");
639        create_test_layer(&s1, &[("a.txt", "aaa")]);
640        cache.put("sha256:layer1", &s1).unwrap();
641
642        let s2 = tmp.path().join("s2");
643        create_test_layer(&s2, &[("b.txt", "bbb")]);
644        cache.put("sha256:layer2", &s2).unwrap();
645
646        let entries = cache.list_entries().unwrap();
647        assert_eq!(entries.len(), 2);
648
649        let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
650        assert!(digests.contains(&"sha256:layer1"));
651        assert!(digests.contains(&"sha256:layer2"));
652    }
653
654    #[test]
655    fn test_layer_cache_total_size() {
656        let tmp = TempDir::new().unwrap();
657        let cache = LayerCache::new(tmp.path()).unwrap();
658
659        assert_eq!(cache.total_size().unwrap(), 0);
660
661        let source = tmp.path().join("source");
662        create_test_layer(&source, &[("data.txt", "hello world")]);
663        cache.put("sha256:sized", &source).unwrap();
664
665        let total = cache.total_size().unwrap();
666        assert!(total > 0);
667    }
668
669    #[test]
670    fn test_layer_cache_prune_under_limit() {
671        let tmp = TempDir::new().unwrap();
672        let cache = LayerCache::new(tmp.path()).unwrap();
673
674        let source = tmp.path().join("source");
675        create_test_layer(&source, &[("small.txt", "tiny")]);
676        cache.put("sha256:small", &source).unwrap();
677
678        // Prune with a large limit — nothing should be evicted
679        let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
680        assert_eq!(evicted, 0);
681        assert!(cache.get("sha256:small").unwrap().is_some());
682    }
683
684    #[test]
685    fn test_layer_cache_prune_evicts_oldest() {
686        let tmp = TempDir::new().unwrap();
687        let cache = LayerCache::new(tmp.path()).unwrap();
688
689        // Add three layers with different access times
690        for i in 0..3 {
691            let source = tmp.path().join(format!("s{}", i));
692            // Create a file with enough content to matter
693            create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
694            cache.put(&format!("sha256:layer{}", i), &source).unwrap();
695            // Small delay to ensure different timestamps
696            std::thread::sleep(std::time::Duration::from_millis(10));
697        }
698
699        // Access layer2 to make it most recently used
700        cache.get("sha256:layer2").unwrap();
701
702        // Prune to a very small limit — should evict oldest first
703        let evicted = cache.prune(1).unwrap();
704        assert!(evicted >= 2);
705
706        // layer2 was most recently accessed, so it should survive longest
707        // (though with limit=1 byte, all may be evicted)
708    }
709
710    #[test]
711    fn test_layer_cache_metadata_persists() {
712        let tmp = TempDir::new().unwrap();
713        let cache = LayerCache::new(tmp.path()).unwrap();
714        let digest = "sha256:meta_test";
715
716        let source = tmp.path().join("source");
717        create_test_layer(&source, &[("file.txt", "content")]);
718        cache.put(digest, &source).unwrap();
719
720        // Read metadata directly
721        let meta_path = tmp.path().join("sha256_meta_test.meta.json");
722        assert!(meta_path.is_file());
723
724        let content = std::fs::read_to_string(&meta_path).unwrap();
725        let meta: LayerMeta = serde_json::from_str(&content).unwrap();
726
727        assert_eq!(meta.digest, digest);
728        assert!(meta.size_bytes > 0);
729        assert!(meta.cached_at > 0);
730        assert_eq!(meta.cached_at, meta.last_accessed);
731    }
732
733    #[test]
734    fn test_digest_to_dirname() {
735        assert_eq!(
736            LayerCache::digest_to_dirname("sha256:abc123"),
737            "sha256_abc123"
738        );
739        assert_eq!(
740            LayerCache::digest_to_dirname("plain_digest"),
741            "plain_digest"
742        );
743    }
744
745    #[test]
746    fn test_copy_dir_recursive() {
747        let tmp = TempDir::new().unwrap();
748        let src = tmp.path().join("src");
749        let dst = tmp.path().join("dst");
750
751        create_test_layer(
752            &src,
753            &[
754                ("a.txt", "aaa"),
755                ("sub/b.txt", "bbb"),
756                ("sub/deep/c.txt", "ccc"),
757            ],
758        );
759
760        copy_dir_recursive(&src, &dst).unwrap();
761
762        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
763        assert_eq!(
764            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
765            "bbb"
766        );
767        assert_eq!(
768            std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
769            "ccc"
770        );
771    }
772
773    #[test]
774    fn test_dir_size() {
775        let tmp = TempDir::new().unwrap();
776        let dir = tmp.path().join("sized");
777        create_test_layer(
778            &dir,
779            &[
780                ("a.txt", "hello"),     // 5 bytes
781                ("sub/b.txt", "world"), // 5 bytes
782            ],
783        );
784
785        let size = dir_size(&dir).unwrap();
786        assert_eq!(size, 10);
787    }
788
789    #[test]
790    fn test_dir_size_empty_directory() {
791        let tmp = TempDir::new().unwrap();
792        let dir = tmp.path().join("empty");
793        std::fs::create_dir_all(&dir).unwrap();
794
795        let size = dir_size(&dir).unwrap();
796        assert_eq!(size, 0);
797    }
798
799    #[test]
800    fn test_dir_size_nonexistent_returns_zero() {
801        let tmp = TempDir::new().unwrap();
802        let dir = tmp.path().join("nonexistent");
803
804        // Not a directory, so returns 0
805        let size = dir_size(&dir).unwrap();
806        assert_eq!(size, 0);
807    }
808
809    #[test]
810    fn test_copy_dir_recursive_empty_directory() {
811        let tmp = TempDir::new().unwrap();
812        let src = tmp.path().join("empty_src");
813        let dst = tmp.path().join("empty_dst");
814        std::fs::create_dir_all(&src).unwrap();
815
816        copy_dir_recursive(&src, &dst).unwrap();
817        assert!(dst.is_dir());
818    }
819
820    #[test]
821    fn test_copy_dir_recursive_source_not_exists() {
822        let tmp = TempDir::new().unwrap();
823        let src = tmp.path().join("nonexistent");
824        let dst = tmp.path().join("dst");
825
826        let result = copy_dir_recursive(&src, &dst);
827        assert!(result.is_err());
828    }
829
830    #[test]
831    fn test_layer_cache_get_updates_last_accessed() {
832        let tmp = TempDir::new().unwrap();
833        let cache = LayerCache::new(tmp.path()).unwrap();
834        let digest = "sha256:access_test";
835
836        let source = tmp.path().join("source");
837        create_test_layer(&source, &[("f.txt", "data")]);
838        cache.put(digest, &source).unwrap();
839
840        // Read initial metadata
841        let meta_path = tmp.path().join("sha256_access_test.meta.json");
842        let content = std::fs::read_to_string(&meta_path).unwrap();
843        let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
844
845        // Small delay to ensure timestamp difference
846        std::thread::sleep(std::time::Duration::from_millis(10));
847
848        // Access the cache entry
849        cache.get(digest).unwrap();
850
851        // Read updated metadata
852        let content = std::fs::read_to_string(&meta_path).unwrap();
853        let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
854
855        assert!(meta_after.last_accessed >= meta_before.last_accessed);
856        // cached_at should not change
857        assert_eq!(meta_after.cached_at, meta_before.cached_at);
858    }
859
860    #[test]
861    fn test_layer_cache_get_corrupted_metadata() {
862        let tmp = TempDir::new().unwrap();
863        let cache = LayerCache::new(tmp.path()).unwrap();
864        let digest = "sha256:corrupted";
865        let safe_name = LayerCache::digest_to_dirname(digest);
866
867        // Create layer directory manually
868        let layer_dir = tmp.path().join(&safe_name);
869        std::fs::create_dir_all(&layer_dir).unwrap();
870
871        // Write corrupted metadata
872        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
873        std::fs::write(&meta_path, "not valid json!!!").unwrap();
874
875        // get() should still return Some (directory exists, metadata is best-effort)
876        // The directory exists and meta file exists, so it returns the path
877        let result = cache.get(digest).unwrap();
878        assert!(result.is_some());
879    }
880
881    #[test]
882    fn test_layer_cache_get_directory_without_metadata() {
883        let tmp = TempDir::new().unwrap();
884        let cache = LayerCache::new(tmp.path()).unwrap();
885        let digest = "sha256:no_meta";
886        let safe_name = LayerCache::digest_to_dirname(digest);
887
888        // Create layer directory but no metadata file
889        let layer_dir = tmp.path().join(&safe_name);
890        std::fs::create_dir_all(&layer_dir).unwrap();
891
892        // Should return None (metadata missing)
893        let result = cache.get(digest).unwrap();
894        assert!(result.is_none());
895    }
896
897    #[test]
898    fn test_layer_cache_get_metadata_without_directory() {
899        let tmp = TempDir::new().unwrap();
900        let cache = LayerCache::new(tmp.path()).unwrap();
901        let digest = "sha256:no_dir";
902        let safe_name = LayerCache::digest_to_dirname(digest);
903
904        // Create metadata file but no layer directory
905        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
906        let meta = LayerMeta {
907            digest: digest.to_string(),
908            size_bytes: 0,
909            cached_at: 0,
910            last_accessed: 0,
911        };
912        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
913
914        // Should return None (directory missing)
915        let result = cache.get(digest).unwrap();
916        assert!(result.is_none());
917    }
918
919    #[test]
920    fn test_layer_cache_put_source_not_exists() {
921        let tmp = TempDir::new().unwrap();
922        let cache = LayerCache::new(tmp.path()).unwrap();
923
924        let nonexistent = tmp.path().join("does_not_exist");
925        let result = cache.put("sha256:bad_source", &nonexistent);
926        assert!(result.is_err());
927    }
928
929    #[test]
930    fn test_layer_cache_prune_zero_limit() {
931        let tmp = TempDir::new().unwrap();
932        let cache = LayerCache::new(tmp.path()).unwrap();
933
934        let source = tmp.path().join("source");
935        create_test_layer(&source, &[("f.txt", "data")]);
936        cache.put("sha256:entry1", &source).unwrap();
937        cache.put("sha256:entry2", &source).unwrap();
938
939        // Prune with 0 bytes limit — should evict everything
940        let evicted = cache.prune(0).unwrap();
941        assert_eq!(evicted, 2);
942        assert_eq!(cache.list_entries().unwrap().len(), 0);
943    }
944
945    #[test]
946    fn test_layer_cache_list_entries_ignores_non_meta_files() {
947        let tmp = TempDir::new().unwrap();
948        let cache = LayerCache::new(tmp.path()).unwrap();
949
950        // Add a valid entry
951        let source = tmp.path().join("source");
952        create_test_layer(&source, &[("f.txt", "data")]);
953        cache.put("sha256:valid", &source).unwrap();
954
955        // Add random non-meta files to cache directory
956        std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
957        std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
958
959        // Should only return the valid entry
960        let entries = cache.list_entries().unwrap();
961        assert_eq!(entries.len(), 1);
962        assert_eq!(entries[0].digest, "sha256:valid");
963    }
964
965    #[test]
966    fn test_layer_cache_list_entries_skips_invalid_json() {
967        let tmp = TempDir::new().unwrap();
968        let cache = LayerCache::new(tmp.path()).unwrap();
969
970        // Add a valid entry
971        let source = tmp.path().join("source");
972        create_test_layer(&source, &[("f.txt", "data")]);
973        cache.put("sha256:valid", &source).unwrap();
974
975        // Add a corrupted .meta.json
976        std::fs::write(
977            tmp.path().join("sha256_corrupted.meta.json"),
978            "not json at all",
979        )
980        .unwrap();
981
982        // Should only return the valid entry, skip corrupted
983        let entries = cache.list_entries().unwrap();
984        assert_eq!(entries.len(), 1);
985        assert_eq!(entries[0].digest, "sha256:valid");
986    }
987
988    #[test]
989    fn test_layer_cache_put_preserves_file_content() {
990        let tmp = TempDir::new().unwrap();
991        let cache = LayerCache::new(tmp.path()).unwrap();
992
993        let source = tmp.path().join("source");
994        create_test_layer(
995            &source,
996            &[
997                ("binary.bin", "\x00\x01\x02\x03"),
998                ("text.txt", "hello world\n"),
999            ],
1000        );
1001
1002        let cached = cache.put("sha256:content_check", &source).unwrap();
1003
1004        assert_eq!(
1005            std::fs::read(cached.join("binary.bin")).unwrap(),
1006            b"\x00\x01\x02\x03"
1007        );
1008        assert_eq!(
1009            std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1010            "hello world\n"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_layer_cache_multiple_colons_in_digest() {
1016        let tmp = TempDir::new().unwrap();
1017        let cache = LayerCache::new(tmp.path()).unwrap();
1018
1019        let digest = "sha256:abc:def:ghi";
1020        let source = tmp.path().join("source");
1021        create_test_layer(&source, &[("f.txt", "data")]);
1022
1023        cache.put(digest, &source).unwrap();
1024        let result = cache.get(digest).unwrap();
1025        assert!(result.is_some());
1026
1027        cache.invalidate(digest).unwrap();
1028        assert!(cache.get(digest).unwrap().is_none());
1029    }
1030
1031    #[test]
1032    fn test_copy_file_cow_preserves_content_and_mode() {
1033        // Works whether the FS supports reflink (FICLONE) or falls back to a byte
1034        // copy — both must preserve content and the permission bits.
1035        let tmp = TempDir::new().unwrap();
1036        let src = tmp.path().join("src.bin");
1037        let dst = tmp.path().join("dst.bin");
1038        std::fs::write(&src, b"hello copy-on-write").unwrap();
1039        #[cfg(unix)]
1040        {
1041            use std::os::unix::fs::PermissionsExt;
1042            std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1043        }
1044
1045        copy_file_cow(&src, &dst).unwrap();
1046
1047        assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1048        #[cfg(unix)]
1049        {
1050            use std::os::unix::fs::PermissionsExt;
1051            let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1052            assert_eq!(mode, 0o755, "executable bit must survive the copy");
1053        }
1054    }
1055
1056    #[test]
1057    fn test_copy_file_cow_overwrites_existing_dst() {
1058        // FICLONE and the fs::copy fallback both truncate the destination.
1059        let tmp = TempDir::new().unwrap();
1060        let src = tmp.path().join("src");
1061        let dst = tmp.path().join("dst");
1062        std::fs::write(&src, b"new").unwrap();
1063        std::fs::write(&dst, b"old-and-longer").unwrap();
1064        copy_file_cow(&src, &dst).unwrap();
1065        assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1066    }
1067}