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