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    #[cfg(target_os = "macos")]
259    {
260        let dst_preexisted = dst.exists();
261        if copy_dir_recursive_cow(src, dst).unwrap_or(false) {
262            return Ok(());
263        }
264        if !dst_preexisted && dst.exists() {
265            let _ = std::fs::remove_dir_all(dst);
266        }
267    }
268
269    std::fs::create_dir_all(dst).map_err(|e| {
270        BoxError::CacheError(format!(
271            "Failed to create directory {}: {}",
272            dst.display(),
273            e
274        ))
275    })?;
276    // Mirror the source directory's ownership onto the destination (root only).
277    if let Ok(src_meta) = std::fs::symlink_metadata(src) {
278        preserve_owner(&src_meta, dst);
279    }
280
281    for entry in std::fs::read_dir(src).map_err(|e| {
282        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
283    })? {
284        let entry = entry
285            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
286        let src_path = entry.path();
287        let dst_path = dst.join(entry.file_name());
288
289        // Use symlink_metadata so is_symlink() works correctly (does not follow links).
290        let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
291            BoxError::CacheError(format!(
292                "Failed to read metadata for {}: {}",
293                src_path.display(),
294                e
295            ))
296        })?;
297
298        if meta.is_symlink() {
299            #[cfg(unix)]
300            {
301                let target = std::fs::read_link(&src_path).map_err(|e| {
302                    BoxError::CacheError(format!(
303                        "Failed to read symlink {}: {}",
304                        src_path.display(),
305                        e
306                    ))
307                })?;
308                std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
309                    BoxError::CacheError(format!(
310                        "Failed to create symlink {} -> {}: {}",
311                        dst_path.display(),
312                        target.display(),
313                        e
314                    ))
315                })?;
316                preserve_owner(&meta, &dst_path);
317            }
318            #[cfg(not(unix))]
319            {
320                return Err(BoxError::CacheError(format!(
321                    "Symlink copy is not supported on this platform: {}",
322                    src_path.display()
323                )));
324            }
325        } else if meta.is_dir() {
326            copy_dir_recursive(&src_path, &dst_path)?;
327        } else {
328            copy_file_cow(&src_path, &dst_path).map_err(|e| {
329                BoxError::CacheError(format!(
330                    "Failed to copy {} to {}: {}",
331                    src_path.display(),
332                    dst_path.display(),
333                    e
334                ))
335            })?;
336            preserve_owner(&meta, &dst_path);
337        }
338    }
339
340    Ok(())
341}
342
343#[cfg(target_os = "macos")]
344fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
345    use std::os::unix::ffi::OsStrExt;
346
347    let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
348        std::io::Error::new(
349            std::io::ErrorKind::InvalidInput,
350            "source path contains a NUL byte",
351        )
352    })?;
353    let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
354        std::io::Error::new(
355            std::io::ErrorKind::InvalidInput,
356            "destination path contains a NUL byte",
357        )
358    })?;
359    let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
360    // SAFETY: both C strings are valid NUL-terminated paths for the duration of
361    // the call. A non-zero result means the system fast path could not handle
362    // this tree, so callers fall back to the portable recursive copy.
363    let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
364    Ok(rc == 0)
365}
366
367/// Copy a regular file, preferring copy-on-write cloning so a new box's rootfs
368/// shares blocks with the cached image — instant, no extra disk — on capable
369/// filesystems (Linux FICLONE, macOS APFS clonefile). Falls back to a plain byte
370/// copy when cloning is unsupported or the source and destination are on
371/// different filesystems. Overlay is preferred on Linux, so this mostly helps
372/// macOS/HVF and Linux `CopyProvider` fallback paths.
373fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
374    #[cfg(target_os = "linux")]
375    {
376        use std::os::unix::io::AsRawFd;
377        // FICLONE = _IOW(0x94, 9, int)
378        const FICLONE: libc::c_ulong = 0x4004_9409;
379        let reflinked = (|| -> std::io::Result<bool> {
380            let s = std::fs::File::open(src)?;
381            let d = std::fs::OpenOptions::new()
382                .write(true)
383                .create(true)
384                .truncate(true)
385                .open(dst)?;
386            // SAFETY: FICLONE's argument is the source fd; both fds are valid for
387            // the call. A non-zero return (unsupported FS / cross-device) just
388            // means "fall back to a byte copy".
389            let rc = unsafe { libc::ioctl(d.as_raw_fd(), FICLONE, s.as_raw_fd()) };
390            if rc != 0 {
391                return Ok(false);
392            }
393            // FICLONE clones data only — copy the permission bits like fs::copy.
394            if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
395                let _ = d.set_permissions(perm);
396            }
397            Ok(true)
398        })()
399        .unwrap_or(false);
400        if reflinked {
401            return Ok(());
402        }
403    }
404
405    #[cfg(target_os = "macos")]
406    {
407        use std::os::unix::ffi::OsStrExt;
408
409        let cloned = (|| -> std::io::Result<bool> {
410            let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
411                std::io::Error::new(
412                    std::io::ErrorKind::InvalidInput,
413                    "source path contains a NUL byte",
414                )
415            })?;
416            let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
417                std::io::Error::new(
418                    std::io::ErrorKind::InvalidInput,
419                    "destination path contains a NUL byte",
420                )
421            })?;
422
423            // SAFETY: both C strings are valid NUL-terminated paths for the
424            // duration of the call. A non-zero result means clonefile is not
425            // available for this path/filesystem and we fall back to byte copy.
426            let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
427            if rc != 0 {
428                return Ok(false);
429            }
430            if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
431                let _ = std::fs::set_permissions(dst, perm);
432            }
433            Ok(true)
434        })()
435        .unwrap_or(false);
436        if cloned {
437            return Ok(());
438        }
439    }
440
441    std::fs::copy(src, dst).map(|_| ())
442}
443
444/// Atomically publish `source_dir`'s contents as the content-addressed cache
445/// entry `dest_dir`. Returns `true` if THIS call created the entry, `false` if
446/// an entry was already present (a concurrent put of the same key won).
447///
448/// Copying straight into `dest_dir` (and `remove_dir_all`-ing a pre-existing
449/// one) corrupts the cache when two processes pull the same layer at once: one
450/// deletes the other's half-copied directory, or both interleave files into the
451/// same path. Instead, copy into a unique staging dir under `staging_parent`,
452/// then `rename` it into place. Because both writers use the same key (a
453/// content digest), an entry that already exists is byte-identical, so a lost
454/// rename race is harmless — we keep the winner and drop our staging copy. The
455/// rename is atomic on the same filesystem, so `dest_dir` is only ever absent or
456/// fully populated, never partial.
457pub(crate) fn publish_dir_atomically(
458    source_dir: &Path,
459    dest_dir: &Path,
460    staging_parent: &Path,
461) -> Result<bool> {
462    if dest_dir.exists() {
463        return Ok(false);
464    }
465    let staging = tempfile::Builder::new()
466        .prefix(".staging-")
467        .tempdir_in(staging_parent)
468        .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
469    // copy_dir_recursive create_dir_all's its destination, so copy into a fresh
470    // subpath of the (already-existing) staging dir, then rename that subpath.
471    let staged = staging.path().join("d");
472    copy_dir_recursive(source_dir, &staged)?;
473
474    match std::fs::rename(&staged, dest_dir) {
475        Ok(()) => Ok(true),
476        // Lost the race: a concurrent put populated dest_dir first. Same key ⇒
477        // identical content, so keep the winner (staging auto-removes on drop).
478        Err(_) if dest_dir.exists() => Ok(false),
479        Err(e) => Err(BoxError::CacheError(format!(
480            "Failed to publish cache entry {}: {e}",
481            dest_dir.display()
482        ))),
483    }
484}
485
486/// Atomically write `json` to `meta_path` (unique temp + rename), so a
487/// concurrent reader never sees a half-written metadata file.
488pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
489    let parent = meta_path.parent().ok_or_else(|| {
490        BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
491    })?;
492    let mut tmp = tempfile::NamedTempFile::new_in(parent)
493        .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
494    use std::io::Write as _;
495    tmp.write_all(json.as_bytes())
496        .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
497    tmp.persist(meta_path)
498        .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
499    Ok(())
500}
501
502/// Calculate the total size of a directory recursively.
503pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
504    let mut total = 0;
505    if path.is_dir() {
506        for entry in std::fs::read_dir(path)? {
507            let entry = entry?;
508            let path = entry.path();
509            if path.is_dir() {
510                total += dir_size(&path)?;
511            } else {
512                total += entry.metadata()?.len();
513            }
514        }
515    }
516    Ok(total)
517}
518
519impl CacheBackend for LayerCache {
520    fn get(&self, key: &str) -> Result<Option<PathBuf>> {
521        self.get(key)
522    }
523
524    fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
525        self.put(key, source_dir)
526    }
527
528    fn invalidate(&self, key: &str) -> Result<()> {
529        self.invalidate(key)
530    }
531
532    fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
533        self.prune(max_bytes)
534    }
535
536    fn list(&self) -> Result<Vec<CacheEntry>> {
537        self.list_entries().map(|entries| {
538            entries
539                .into_iter()
540                .map(|m| CacheEntry {
541                    key: m.digest,
542                    description: String::new(),
543                    size_bytes: m.size_bytes,
544                    cached_at: m.cached_at,
545                    last_accessed: m.last_accessed,
546                })
547                .collect()
548        })
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use tempfile::TempDir;
556
557    fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
558        std::fs::create_dir_all(dir).unwrap();
559        for (name, content) in files {
560            let file_path = dir.join(name);
561            if let Some(parent) = file_path.parent() {
562                std::fs::create_dir_all(parent).unwrap();
563            }
564            std::fs::write(&file_path, content).unwrap();
565        }
566    }
567
568    #[test]
569    fn test_layer_cache_new_creates_directory() {
570        let tmp = TempDir::new().unwrap();
571        let cache_dir = tmp.path().join("layers");
572
573        assert!(!cache_dir.exists());
574        let _cache = LayerCache::new(&cache_dir).unwrap();
575        assert!(cache_dir.is_dir());
576    }
577
578    #[test]
579    fn test_layer_cache_get_miss() {
580        let tmp = TempDir::new().unwrap();
581        let cache = LayerCache::new(tmp.path()).unwrap();
582
583        let result = cache.get("sha256:nonexistent").unwrap();
584        assert!(result.is_none());
585    }
586
587    #[test]
588    fn test_layer_cache_put_and_get() {
589        let tmp = TempDir::new().unwrap();
590        let cache = LayerCache::new(tmp.path()).unwrap();
591
592        // Create a source layer directory
593        let source = tmp.path().join("source_layer");
594        create_test_layer(
595            &source,
596            &[("file.txt", "hello"), ("sub/nested.txt", "world")],
597        );
598
599        // Put into cache
600        let digest = "sha256:abc123def456";
601        let cached_path = cache.put(digest, &source).unwrap();
602
603        assert!(cached_path.is_dir());
604        assert!(cached_path.join("file.txt").is_file());
605        assert!(cached_path.join("sub/nested.txt").is_file());
606
607        // Get from cache
608        let result = cache.get(digest).unwrap();
609        assert!(result.is_some());
610        assert_eq!(result.unwrap(), cached_path);
611    }
612
613    #[test]
614    fn test_layer_cache_put_same_digest_is_idempotent() {
615        // A layer digest IS the hash of its content, so the same digest can only
616        // ever map to identical content — re-putting it must be a no-op that
617        // keeps the first entry, not a remove-and-recopy (which corrupts the
618        // cache when two pulls of the same layer race). The "different content"
619        // below is an impossible-in-reality stand-in to prove the first write wins.
620        let tmp = TempDir::new().unwrap();
621        let cache = LayerCache::new(tmp.path()).unwrap();
622        let digest = "sha256:idempotent_test";
623
624        let source1 = tmp.path().join("v1");
625        create_test_layer(&source1, &[("v1.txt", "version 1")]);
626        let first = cache.put(digest, &source1).unwrap();
627
628        let source2 = tmp.path().join("v2");
629        create_test_layer(&source2, &[("v2.txt", "version 2")]);
630        let second = cache.put(digest, &source2).unwrap();
631
632        // Same cache path, first content preserved (idempotent, no overwrite).
633        assert_eq!(first, second);
634        assert!(second.join("v1.txt").is_file());
635        assert!(!second.join("v2.txt").exists());
636    }
637
638    #[test]
639    fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
640        use std::sync::Arc;
641
642        let tmp = TempDir::new().unwrap();
643        let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
644        let digest = "sha256:concurrent_test";
645
646        // Several identical source layers (like the same layer extracted by N
647        // racing pulls), each with the same multi-file content.
648        let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
649        let handles: Vec<_> = (0..12)
650            .map(|i| {
651                let cache = Arc::clone(&cache);
652                let src = tmp.path().join(format!("src{i}"));
653                create_test_layer(&src, files);
654                std::thread::spawn(move || cache.put(digest, &src).unwrap())
655            })
656            .collect();
657        let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
658
659        // Every put returns the same cache dir, and it is COMPLETE (no half-copied
660        // / interleaved layer): all files present with the right content.
661        for p in &paths {
662            assert_eq!(p, &paths[0]);
663            assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
664            assert_eq!(
665                std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
666                "beta"
667            );
668        }
669        assert!(cache.get(digest).unwrap().is_some());
670    }
671
672    #[test]
673    fn test_layer_cache_invalidate() {
674        let tmp = TempDir::new().unwrap();
675        let cache = LayerCache::new(tmp.path()).unwrap();
676        let digest = "sha256:to_invalidate";
677
678        let source = tmp.path().join("source");
679        create_test_layer(&source, &[("data.bin", "binary data")]);
680        cache.put(digest, &source).unwrap();
681
682        // Verify it exists
683        assert!(cache.get(digest).unwrap().is_some());
684
685        // Invalidate
686        cache.invalidate(digest).unwrap();
687
688        // Should be gone
689        assert!(cache.get(digest).unwrap().is_none());
690    }
691
692    #[test]
693    fn test_layer_cache_invalidate_nonexistent() {
694        let tmp = TempDir::new().unwrap();
695        let cache = LayerCache::new(tmp.path()).unwrap();
696
697        // Should not error on nonexistent digest
698        cache.invalidate("sha256:does_not_exist").unwrap();
699    }
700
701    #[test]
702    fn test_layer_cache_list_entries() {
703        let tmp = TempDir::new().unwrap();
704        let cache = LayerCache::new(tmp.path()).unwrap();
705
706        // Empty cache
707        assert_eq!(cache.list_entries().unwrap().len(), 0);
708
709        // Add two layers
710        let s1 = tmp.path().join("s1");
711        create_test_layer(&s1, &[("a.txt", "aaa")]);
712        cache.put("sha256:layer1", &s1).unwrap();
713
714        let s2 = tmp.path().join("s2");
715        create_test_layer(&s2, &[("b.txt", "bbb")]);
716        cache.put("sha256:layer2", &s2).unwrap();
717
718        let entries = cache.list_entries().unwrap();
719        assert_eq!(entries.len(), 2);
720
721        let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
722        assert!(digests.contains(&"sha256:layer1"));
723        assert!(digests.contains(&"sha256:layer2"));
724    }
725
726    #[test]
727    fn test_layer_cache_total_size() {
728        let tmp = TempDir::new().unwrap();
729        let cache = LayerCache::new(tmp.path()).unwrap();
730
731        assert_eq!(cache.total_size().unwrap(), 0);
732
733        let source = tmp.path().join("source");
734        create_test_layer(&source, &[("data.txt", "hello world")]);
735        cache.put("sha256:sized", &source).unwrap();
736
737        let total = cache.total_size().unwrap();
738        assert!(total > 0);
739    }
740
741    #[test]
742    fn test_layer_cache_prune_under_limit() {
743        let tmp = TempDir::new().unwrap();
744        let cache = LayerCache::new(tmp.path()).unwrap();
745
746        let source = tmp.path().join("source");
747        create_test_layer(&source, &[("small.txt", "tiny")]);
748        cache.put("sha256:small", &source).unwrap();
749
750        // Prune with a large limit — nothing should be evicted
751        let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
752        assert_eq!(evicted, 0);
753        assert!(cache.get("sha256:small").unwrap().is_some());
754    }
755
756    #[test]
757    fn test_layer_cache_prune_evicts_oldest() {
758        let tmp = TempDir::new().unwrap();
759        let cache = LayerCache::new(tmp.path()).unwrap();
760
761        // Add three layers with different access times
762        for i in 0..3 {
763            let source = tmp.path().join(format!("s{}", i));
764            // Create a file with enough content to matter
765            create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
766            cache.put(&format!("sha256:layer{}", i), &source).unwrap();
767            // Small delay to ensure different timestamps
768            std::thread::sleep(std::time::Duration::from_millis(10));
769        }
770
771        // Access layer2 to make it most recently used
772        cache.get("sha256:layer2").unwrap();
773
774        // Prune to a very small limit — should evict oldest first
775        let evicted = cache.prune(1).unwrap();
776        assert!(evicted >= 2);
777
778        // layer2 was most recently accessed, so it should survive longest
779        // (though with limit=1 byte, all may be evicted)
780    }
781
782    #[test]
783    fn test_layer_cache_metadata_persists() {
784        let tmp = TempDir::new().unwrap();
785        let cache = LayerCache::new(tmp.path()).unwrap();
786        let digest = "sha256:meta_test";
787
788        let source = tmp.path().join("source");
789        create_test_layer(&source, &[("file.txt", "content")]);
790        cache.put(digest, &source).unwrap();
791
792        // Read metadata directly
793        let meta_path = tmp.path().join("sha256_meta_test.meta.json");
794        assert!(meta_path.is_file());
795
796        let content = std::fs::read_to_string(&meta_path).unwrap();
797        let meta: LayerMeta = serde_json::from_str(&content).unwrap();
798
799        assert_eq!(meta.digest, digest);
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_digest_to_dirname() {
807        assert_eq!(
808            LayerCache::digest_to_dirname("sha256:abc123"),
809            "sha256_abc123"
810        );
811        assert_eq!(
812            LayerCache::digest_to_dirname("plain_digest"),
813            "plain_digest"
814        );
815    }
816
817    #[test]
818    fn test_copy_dir_recursive() {
819        let tmp = TempDir::new().unwrap();
820        let src = tmp.path().join("src");
821        let dst = tmp.path().join("dst");
822
823        create_test_layer(
824            &src,
825            &[
826                ("a.txt", "aaa"),
827                ("sub/b.txt", "bbb"),
828                ("sub/deep/c.txt", "ccc"),
829            ],
830        );
831
832        copy_dir_recursive(&src, &dst).unwrap();
833
834        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
835        assert_eq!(
836            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
837            "bbb"
838        );
839        assert_eq!(
840            std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
841            "ccc"
842        );
843    }
844
845    #[cfg(unix)]
846    #[test]
847    fn test_copy_dir_recursive_preserves_symlinks() {
848        let tmp = TempDir::new().unwrap();
849        let src = tmp.path().join("src");
850        let dst = tmp.path().join("dst");
851        std::fs::create_dir_all(&src).unwrap();
852        std::fs::write(src.join("target.txt"), "target").unwrap();
853        std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
854
855        copy_dir_recursive(&src, &dst).unwrap();
856
857        let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
858        assert!(link_meta.file_type().is_symlink());
859        assert_eq!(
860            std::fs::read_link(dst.join("link.txt")).unwrap(),
861            std::path::PathBuf::from("target.txt")
862        );
863    }
864
865    #[test]
866    fn test_dir_size() {
867        let tmp = TempDir::new().unwrap();
868        let dir = tmp.path().join("sized");
869        create_test_layer(
870            &dir,
871            &[
872                ("a.txt", "hello"),     // 5 bytes
873                ("sub/b.txt", "world"), // 5 bytes
874            ],
875        );
876
877        let size = dir_size(&dir).unwrap();
878        assert_eq!(size, 10);
879    }
880
881    #[test]
882    fn test_dir_size_empty_directory() {
883        let tmp = TempDir::new().unwrap();
884        let dir = tmp.path().join("empty");
885        std::fs::create_dir_all(&dir).unwrap();
886
887        let size = dir_size(&dir).unwrap();
888        assert_eq!(size, 0);
889    }
890
891    #[test]
892    fn test_dir_size_nonexistent_returns_zero() {
893        let tmp = TempDir::new().unwrap();
894        let dir = tmp.path().join("nonexistent");
895
896        // Not a directory, so returns 0
897        let size = dir_size(&dir).unwrap();
898        assert_eq!(size, 0);
899    }
900
901    #[test]
902    fn test_copy_dir_recursive_empty_directory() {
903        let tmp = TempDir::new().unwrap();
904        let src = tmp.path().join("empty_src");
905        let dst = tmp.path().join("empty_dst");
906        std::fs::create_dir_all(&src).unwrap();
907
908        copy_dir_recursive(&src, &dst).unwrap();
909        assert!(dst.is_dir());
910    }
911
912    #[test]
913    fn test_copy_dir_recursive_source_not_exists() {
914        let tmp = TempDir::new().unwrap();
915        let src = tmp.path().join("nonexistent");
916        let dst = tmp.path().join("dst");
917
918        let result = copy_dir_recursive(&src, &dst);
919        assert!(result.is_err());
920    }
921
922    #[test]
923    fn test_layer_cache_get_updates_last_accessed() {
924        let tmp = TempDir::new().unwrap();
925        let cache = LayerCache::new(tmp.path()).unwrap();
926        let digest = "sha256:access_test";
927
928        let source = tmp.path().join("source");
929        create_test_layer(&source, &[("f.txt", "data")]);
930        cache.put(digest, &source).unwrap();
931
932        // Read initial metadata
933        let meta_path = tmp.path().join("sha256_access_test.meta.json");
934        let content = std::fs::read_to_string(&meta_path).unwrap();
935        let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
936
937        // Small delay to ensure timestamp difference
938        std::thread::sleep(std::time::Duration::from_millis(10));
939
940        // Access the cache entry
941        cache.get(digest).unwrap();
942
943        // Read updated metadata
944        let content = std::fs::read_to_string(&meta_path).unwrap();
945        let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
946
947        assert!(meta_after.last_accessed >= meta_before.last_accessed);
948        // cached_at should not change
949        assert_eq!(meta_after.cached_at, meta_before.cached_at);
950    }
951
952    #[test]
953    fn test_layer_cache_get_corrupted_metadata() {
954        let tmp = TempDir::new().unwrap();
955        let cache = LayerCache::new(tmp.path()).unwrap();
956        let digest = "sha256:corrupted";
957        let safe_name = LayerCache::digest_to_dirname(digest);
958
959        // Create layer directory manually
960        let layer_dir = tmp.path().join(&safe_name);
961        std::fs::create_dir_all(&layer_dir).unwrap();
962
963        // Write corrupted metadata
964        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
965        std::fs::write(&meta_path, "not valid json!!!").unwrap();
966
967        // get() should still return Some (directory exists, metadata is best-effort)
968        // The directory exists and meta file exists, so it returns the path
969        let result = cache.get(digest).unwrap();
970        assert!(result.is_some());
971    }
972
973    #[test]
974    fn test_layer_cache_get_directory_without_metadata() {
975        let tmp = TempDir::new().unwrap();
976        let cache = LayerCache::new(tmp.path()).unwrap();
977        let digest = "sha256:no_meta";
978        let safe_name = LayerCache::digest_to_dirname(digest);
979
980        // Create layer directory but no metadata file
981        let layer_dir = tmp.path().join(&safe_name);
982        std::fs::create_dir_all(&layer_dir).unwrap();
983
984        // Should return None (metadata missing)
985        let result = cache.get(digest).unwrap();
986        assert!(result.is_none());
987    }
988
989    #[test]
990    fn test_layer_cache_get_metadata_without_directory() {
991        let tmp = TempDir::new().unwrap();
992        let cache = LayerCache::new(tmp.path()).unwrap();
993        let digest = "sha256:no_dir";
994        let safe_name = LayerCache::digest_to_dirname(digest);
995
996        // Create metadata file but no layer directory
997        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
998        let meta = LayerMeta {
999            digest: digest.to_string(),
1000            size_bytes: 0,
1001            cached_at: 0,
1002            last_accessed: 0,
1003        };
1004        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1005
1006        // Should return None (directory missing)
1007        let result = cache.get(digest).unwrap();
1008        assert!(result.is_none());
1009    }
1010
1011    #[test]
1012    fn test_layer_cache_put_source_not_exists() {
1013        let tmp = TempDir::new().unwrap();
1014        let cache = LayerCache::new(tmp.path()).unwrap();
1015
1016        let nonexistent = tmp.path().join("does_not_exist");
1017        let result = cache.put("sha256:bad_source", &nonexistent);
1018        assert!(result.is_err());
1019    }
1020
1021    #[test]
1022    fn test_layer_cache_prune_zero_limit() {
1023        let tmp = TempDir::new().unwrap();
1024        let cache = LayerCache::new(tmp.path()).unwrap();
1025
1026        let source = tmp.path().join("source");
1027        create_test_layer(&source, &[("f.txt", "data")]);
1028        cache.put("sha256:entry1", &source).unwrap();
1029        cache.put("sha256:entry2", &source).unwrap();
1030
1031        // Prune with 0 bytes limit — should evict everything
1032        let evicted = cache.prune(0).unwrap();
1033        assert_eq!(evicted, 2);
1034        assert_eq!(cache.list_entries().unwrap().len(), 0);
1035    }
1036
1037    #[test]
1038    fn test_layer_cache_list_entries_ignores_non_meta_files() {
1039        let tmp = TempDir::new().unwrap();
1040        let cache = LayerCache::new(tmp.path()).unwrap();
1041
1042        // Add a valid entry
1043        let source = tmp.path().join("source");
1044        create_test_layer(&source, &[("f.txt", "data")]);
1045        cache.put("sha256:valid", &source).unwrap();
1046
1047        // Add random non-meta files to cache directory
1048        std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1049        std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1050
1051        // Should only return the valid entry
1052        let entries = cache.list_entries().unwrap();
1053        assert_eq!(entries.len(), 1);
1054        assert_eq!(entries[0].digest, "sha256:valid");
1055    }
1056
1057    #[test]
1058    fn test_layer_cache_list_entries_skips_invalid_json() {
1059        let tmp = TempDir::new().unwrap();
1060        let cache = LayerCache::new(tmp.path()).unwrap();
1061
1062        // Add a valid entry
1063        let source = tmp.path().join("source");
1064        create_test_layer(&source, &[("f.txt", "data")]);
1065        cache.put("sha256:valid", &source).unwrap();
1066
1067        // Add a corrupted .meta.json
1068        std::fs::write(
1069            tmp.path().join("sha256_corrupted.meta.json"),
1070            "not json at all",
1071        )
1072        .unwrap();
1073
1074        // Should only return the valid entry, skip corrupted
1075        let entries = cache.list_entries().unwrap();
1076        assert_eq!(entries.len(), 1);
1077        assert_eq!(entries[0].digest, "sha256:valid");
1078    }
1079
1080    #[test]
1081    fn test_layer_cache_put_preserves_file_content() {
1082        let tmp = TempDir::new().unwrap();
1083        let cache = LayerCache::new(tmp.path()).unwrap();
1084
1085        let source = tmp.path().join("source");
1086        create_test_layer(
1087            &source,
1088            &[
1089                ("binary.bin", "\x00\x01\x02\x03"),
1090                ("text.txt", "hello world\n"),
1091            ],
1092        );
1093
1094        let cached = cache.put("sha256:content_check", &source).unwrap();
1095
1096        assert_eq!(
1097            std::fs::read(cached.join("binary.bin")).unwrap(),
1098            b"\x00\x01\x02\x03"
1099        );
1100        assert_eq!(
1101            std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1102            "hello world\n"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_layer_cache_multiple_colons_in_digest() {
1108        let tmp = TempDir::new().unwrap();
1109        let cache = LayerCache::new(tmp.path()).unwrap();
1110
1111        let digest = "sha256:abc:def:ghi";
1112        let source = tmp.path().join("source");
1113        create_test_layer(&source, &[("f.txt", "data")]);
1114
1115        cache.put(digest, &source).unwrap();
1116        let result = cache.get(digest).unwrap();
1117        assert!(result.is_some());
1118
1119        cache.invalidate(digest).unwrap();
1120        assert!(cache.get(digest).unwrap().is_none());
1121    }
1122
1123    #[test]
1124    fn test_copy_file_cow_preserves_content_and_mode() {
1125        // Works whether the FS supports reflink (FICLONE) or falls back to a byte
1126        // copy — both must preserve content and the permission bits.
1127        let tmp = TempDir::new().unwrap();
1128        let src = tmp.path().join("src.bin");
1129        let dst = tmp.path().join("dst.bin");
1130        std::fs::write(&src, b"hello copy-on-write").unwrap();
1131        #[cfg(unix)]
1132        {
1133            use std::os::unix::fs::PermissionsExt;
1134            std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1135        }
1136
1137        copy_file_cow(&src, &dst).unwrap();
1138
1139        assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1140        #[cfg(unix)]
1141        {
1142            use std::os::unix::fs::PermissionsExt;
1143            let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1144            assert_eq!(mode, 0o755, "executable bit must survive the copy");
1145        }
1146    }
1147
1148    #[test]
1149    fn test_copy_file_cow_overwrites_existing_dst() {
1150        // FICLONE and the fs::copy fallback both truncate the destination.
1151        let tmp = TempDir::new().unwrap();
1152        let src = tmp.path().join("src");
1153        let dst = tmp.path().join("dst");
1154        std::fs::write(&src, b"new").unwrap();
1155        std::fs::write(&dst, b"old-and-longer").unwrap();
1156        copy_file_cow(&src, &dst).unwrap();
1157        assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1158    }
1159}