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        super::validate_cache_key(digest, "layer digest")?;
55        let safe_name = Self::digest_to_dirname(digest);
56        let layer_dir = self.cache_dir.join(&safe_name);
57        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
58
59        if !super::is_real_directory(&layer_dir) || !super::is_regular_file(&meta_path) {
60            return Ok(None);
61        }
62
63        // Update last_accessed timestamp
64        if let Ok(content) = std::fs::read_to_string(&meta_path) {
65            if let Ok(mut meta) = serde_json::from_str::<LayerMeta>(&content) {
66                meta.last_accessed = chrono::Utc::now().timestamp();
67                if let Err(e) =
68                    write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)
69                {
70                    tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update layer cache metadata");
71                }
72            }
73        }
74
75        Ok(Some(layer_dir))
76    }
77
78    /// Store an extracted layer directory in the cache.
79    ///
80    /// Copies the contents of `source_dir` into the cache keyed by `digest`.
81    /// Returns the path to the cached layer directory.
82    pub fn put(&self, digest: &str, source_dir: &Path) -> Result<PathBuf> {
83        super::validate_cache_key(digest, "layer digest")?;
84        let safe_name = Self::digest_to_dirname(digest);
85        let layer_dir = self.cache_dir.join(&safe_name);
86        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
87
88        // Already fully cached (content-addressed ⇒ identical): nothing to do.
89        // Returning early also makes concurrent puts of the same layer idempotent.
90        if super::is_real_directory(&layer_dir) && super::is_regular_file(&meta_path) {
91            return Ok(layer_dir);
92        }
93
94        // Atomically publish the extracted layer (staging dir + rename) so a
95        // concurrent pull of the same layer cannot corrupt the cache by
96        // removing/interleaving a half-copied directory.
97        publish_dir_atomically(source_dir, &layer_dir, &self.cache_dir)?;
98
99        // Calculate size (from whichever copy landed — they are identical).
100        let size_bytes = dir_size(&layer_dir).unwrap_or(0);
101
102        // Write metadata atomically (unique temp + rename).
103        let now = chrono::Utc::now().timestamp();
104        let meta = LayerMeta {
105            digest: digest.to_string(),
106            size_bytes,
107            cached_at: now,
108            last_accessed: now,
109        };
110        write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)?;
111
112        tracing::debug!(
113            digest = %digest,
114            size_bytes,
115            path = %layer_dir.display(),
116            "Cached OCI layer"
117        );
118
119        Ok(layer_dir)
120    }
121
122    /// Remove a cached layer by digest.
123    pub fn invalidate(&self, digest: &str) -> Result<()> {
124        super::validate_cache_key(digest, "layer digest")?;
125        let safe_name = Self::digest_to_dirname(digest);
126        let layer_dir = self.cache_dir.join(&safe_name);
127        let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
128
129        super::remove_path_no_follow(&layer_dir)?;
130        super::remove_path_no_follow(&meta_path)?;
131
132        Ok(())
133    }
134
135    /// Prune the cache to stay within the given byte limit.
136    ///
137    /// Evicts least-recently-accessed entries first.
138    /// Returns the number of entries evicted.
139    pub fn prune(&self, max_bytes: u64) -> Result<usize> {
140        let mut entries = self.list_entries()?;
141
142        // Calculate total size
143        let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
144        if total_size <= max_bytes {
145            return Ok(0);
146        }
147
148        // Sort by last_accessed ascending (oldest first)
149        entries.sort_by_key(|e| e.last_accessed);
150
151        let mut current_size = total_size;
152        let mut evicted = 0;
153
154        for entry in &entries {
155            if current_size <= max_bytes {
156                break;
157            }
158            self.invalidate(&entry.digest)?;
159            current_size = current_size.saturating_sub(entry.size_bytes);
160            evicted += 1;
161
162            tracing::debug!(
163                digest = %entry.digest,
164                size_bytes = entry.size_bytes,
165                "Evicted cached layer"
166            );
167        }
168
169        Ok(evicted)
170    }
171
172    /// List all cached layer entries with their metadata.
173    pub fn list_entries(&self) -> Result<Vec<LayerMeta>> {
174        let mut entries = Vec::new();
175
176        let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
177            BoxError::CacheError(format!(
178                "Failed to read cache directory {}: {}",
179                self.cache_dir.display(),
180                e
181            ))
182        })?;
183
184        for entry in read_dir {
185            let entry = entry.map_err(|e| {
186                BoxError::CacheError(format!("Failed to read directory entry: {}", e))
187            })?;
188            let path = entry.path();
189
190            // Only process .meta.json files
191            if path.extension().and_then(|e| e.to_str()) == Some("json") {
192                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
193                    if name.ends_with(".meta.json") {
194                        if let Ok(content) = std::fs::read_to_string(&path) {
195                            if let Ok(meta) = serde_json::from_str::<LayerMeta>(&content) {
196                                entries.push(meta);
197                            }
198                        }
199                    }
200                }
201            }
202        }
203
204        Ok(entries)
205    }
206
207    /// Get the total size of all cached layers in bytes.
208    pub fn total_size(&self) -> Result<u64> {
209        Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
210    }
211
212    /// Convert a digest string to a safe directory name.
213    ///
214    /// Replaces ':' with '_' to avoid filesystem issues.
215    /// e.g., "sha256:abc123" → "sha256_abc123"
216    fn digest_to_dirname(digest: &str) -> String {
217        digest.replace(':', "_")
218    }
219}
220
221/// Recursively copy a directory and its contents.
222/// Copy `src`'s uid/gid onto `dst` (no symlink follow), best-effort, root only.
223///
224/// `std::fs::copy`/`create_dir_all` do not carry ownership, so a rootfs copied
225/// from extracted layers would collapse to root and lose `COPY --chown` (and
226/// base-image) ownership. Only root can chown to arbitrary ids, so this is a
227/// no-op otherwise.
228#[cfg(unix)]
229fn preserve_owner(meta: &std::fs::Metadata, dst: &Path) {
230    use std::os::unix::ffi::OsStrExt;
231    use std::os::unix::fs::MetadataExt;
232    if unsafe { libc::geteuid() } != 0 {
233        return;
234    }
235    if let Ok(c_path) = std::ffi::CString::new(dst.as_os_str().as_bytes()) {
236        // lchown so a symlink's own ownership is set, not its target's.
237        unsafe {
238            libc::lchown(c_path.as_ptr(), meta.uid(), meta.gid());
239        }
240    }
241}
242
243#[cfg(not(unix))]
244fn preserve_owner(_meta: &std::fs::Metadata, _dst: &Path) {}
245
246pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
247    #[cfg(target_os = "macos")]
248    {
249        let dst_preexisted = dst.exists();
250        // copyfile(3) gives an existing destination directory different
251        // semantics from this helper: it creates `dst/src.file_name()` instead
252        // of copying the source directory's contents directly into `dst`.
253        // APFS rootfs mountpoints already exist, so taking that fast path would
254        // restore a snapshot below `.a3s-rootfs/rootfs/`. Fall back to the
255        // entry-by-entry copy for an existing destination; nested directories
256        // can still use APFS clones once their destination does not exist.
257        if !dst_preexisted && copy_dir_recursive_cow(src, dst).unwrap_or(false) {
258            return Ok(());
259        }
260        if !dst_preexisted && dst.exists() {
261            let _ = std::fs::remove_dir_all(dst);
262        }
263    }
264
265    std::fs::create_dir_all(dst).map_err(|e| {
266        BoxError::CacheError(format!(
267            "Failed to create directory {}: {}",
268            dst.display(),
269            e
270        ))
271    })?;
272    let src_meta = std::fs::symlink_metadata(src).map_err(|e| {
273        BoxError::CacheError(format!(
274            "Failed to read directory metadata for {}: {}",
275            src.display(),
276            e
277        ))
278    })?;
279    // Mirror the source directory's ownership onto the destination (root only).
280    preserve_owner(&src_meta, dst);
281
282    for entry in std::fs::read_dir(src).map_err(|e| {
283        BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
284    })? {
285        let entry = entry
286            .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
287        let src_path = entry.path();
288        let dst_path = dst.join(entry.file_name());
289
290        // Use symlink_metadata so is_symlink() works correctly (does not follow links).
291        let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
292            BoxError::CacheError(format!(
293                "Failed to read metadata for {}: {}",
294                src_path.display(),
295                e
296            ))
297        })?;
298
299        if meta.file_type().is_symlink() {
300            let target = std::fs::read_link(&src_path).map_err(|e| {
301                BoxError::CacheError(format!(
302                    "Failed to read symlink {}: {}",
303                    src_path.display(),
304                    e
305                ))
306            })?;
307            #[cfg(unix)]
308            {
309                std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
310                    BoxError::CacheError(format!(
311                        "Failed to create symlink {} -> {}: {}",
312                        dst_path.display(),
313                        target.display(),
314                        e
315                    ))
316                })?;
317                preserve_owner(&meta, &dst_path);
318            }
319            #[cfg(windows)]
320            {
321                use std::os::windows::fs::MetadataExt;
322
323                // Do not follow the link to determine its type. Absolute Linux
324                // targets such as `/bin/busybox` are intentionally broken from
325                // the Windows host's perspective but valid inside the guest.
326                const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
327                let result = if meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
328                    std::os::windows::fs::symlink_dir(&target, &dst_path)
329                } else {
330                    std::os::windows::fs::symlink_file(&target, &dst_path)
331                };
332                result.map_err(|e| {
333                    BoxError::CacheError(format!(
334                        "Failed to create symlink {} -> {}: {}",
335                        dst_path.display(),
336                        target.display(),
337                        e
338                    ))
339                })?;
340            }
341            #[cfg(not(any(unix, windows)))]
342            {
343                return Err(BoxError::CacheError(format!(
344                    "Symlink copy is not supported on this platform: {}",
345                    src_path.display()
346                )));
347            }
348        } else if meta.is_dir() {
349            copy_dir_recursive(&src_path, &dst_path)?;
350        } else {
351            copy_file_cow(&src_path, &dst_path).map_err(|e| {
352                BoxError::CacheError(format!(
353                    "Failed to copy {} to {}: {}",
354                    src_path.display(),
355                    dst_path.display(),
356                    e
357                ))
358            })?;
359            preserve_owner(&meta, &dst_path);
360        }
361    }
362
363    // Apply directory permissions only after copying its children. This both
364    // preserves image modes in the cache and avoids making a read-only source
365    // directory's destination unwritable before recursion is complete.
366    std::fs::set_permissions(dst, src_meta.permissions()).map_err(|e| {
367        BoxError::CacheError(format!(
368            "Failed to preserve directory permissions on {}: {}",
369            dst.display(),
370            e
371        ))
372    })?;
373
374    Ok(())
375}
376
377#[cfg(target_os = "macos")]
378fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
379    use std::os::unix::ffi::OsStrExt;
380
381    let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
382        std::io::Error::new(
383            std::io::ErrorKind::InvalidInput,
384            "source path contains a NUL byte",
385        )
386    })?;
387    let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
388        std::io::Error::new(
389            std::io::ErrorKind::InvalidInput,
390            "destination path contains a NUL byte",
391        )
392    })?;
393    let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
394    // SAFETY: both C strings are valid NUL-terminated paths for the duration of
395    // the call. A non-zero result means the system fast path could not handle
396    // this tree, so callers fall back to the portable recursive copy.
397    let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
398    Ok(rc == 0)
399}
400
401/// Copy a regular file, preferring copy-on-write cloning so a new box's rootfs
402/// shares blocks with the cached image — instant, no extra disk — on capable
403/// filesystems (Linux FICLONE, macOS APFS clonefile). Falls back to a plain byte
404/// copy when cloning is unsupported or the source and destination are on
405/// different filesystems. Overlay is preferred on Linux, so this mostly helps
406/// macOS/HVF and Linux `CopyProvider` fallback paths.
407pub(crate) fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
408    #[cfg(target_os = "linux")]
409    {
410        use std::os::unix::io::AsRawFd;
411        let reflinked = (|| -> std::io::Result<bool> {
412            let s = std::fs::File::open(src)?;
413            let d = std::fs::OpenOptions::new()
414                .write(true)
415                .create(true)
416                .truncate(true)
417                .open(dst)?;
418            // SAFETY: FICLONE's argument is the source fd; both fds are valid for
419            // the call. A non-zero return (unsupported FS / cross-device) just
420            // means "fall back to a byte copy".
421            // `libc::Ioctl` is `c_ulong` on glibc and `c_int` on musl. Use
422            // libc's target-specific constant instead of fixing the request to
423            // one ABI's integer type.
424            let rc = unsafe { libc::ioctl(d.as_raw_fd(), libc::FICLONE as _, s.as_raw_fd()) };
425            if rc != 0 {
426                return Ok(false);
427            }
428            // FICLONE clones data only — copy the permission bits like fs::copy.
429            if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
430                let _ = d.set_permissions(perm);
431            }
432            Ok(true)
433        })()
434        .unwrap_or(false);
435        if reflinked {
436            return Ok(());
437        }
438    }
439
440    #[cfg(target_os = "macos")]
441    {
442        use std::os::unix::ffi::OsStrExt;
443
444        let cloned = (|| -> std::io::Result<bool> {
445            let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
446                std::io::Error::new(
447                    std::io::ErrorKind::InvalidInput,
448                    "source path contains a NUL byte",
449                )
450            })?;
451            let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
452                std::io::Error::new(
453                    std::io::ErrorKind::InvalidInput,
454                    "destination path contains a NUL byte",
455                )
456            })?;
457
458            // SAFETY: both C strings are valid NUL-terminated paths for the
459            // duration of the call. A non-zero result means clonefile is not
460            // available for this path/filesystem and we fall back to byte copy.
461            let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
462            if rc != 0 {
463                return Ok(false);
464            }
465            if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
466                let _ = std::fs::set_permissions(dst, perm);
467            }
468            Ok(true)
469        })()
470        .unwrap_or(false);
471        if cloned {
472            return Ok(());
473        }
474    }
475
476    std::fs::copy(src, dst).map(|_| ())
477}
478
479/// Atomically publish `source_dir`'s contents as the content-addressed cache
480/// entry `dest_dir`. Returns `true` if THIS call created the entry, `false` if
481/// an entry was already present (a concurrent put of the same key won).
482///
483/// Copying straight into `dest_dir` (and `remove_dir_all`-ing a pre-existing
484/// one) corrupts the cache when two processes pull the same layer at once: one
485/// deletes the other's half-copied directory, or both interleave files into the
486/// same path. Instead, copy into a unique staging dir under `staging_parent`,
487/// then `rename` it into place. Because both writers use the same key (a
488/// content digest), an entry that already exists is byte-identical, so a lost
489/// rename race is harmless — we keep the winner and drop our staging copy. The
490/// rename is atomic on the same filesystem, so `dest_dir` is only ever absent or
491/// fully populated, never partial.
492pub(crate) fn publish_dir_atomically(
493    source_dir: &Path,
494    dest_dir: &Path,
495    staging_parent: &Path,
496) -> Result<bool> {
497    match std::fs::symlink_metadata(dest_dir) {
498        Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
499            return Ok(false)
500        }
501        Ok(_) => {
502            return Err(BoxError::CacheError(format!(
503                "Cache destination is not a real directory: {}",
504                dest_dir.display()
505            )))
506        }
507        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
508        Err(error) => {
509            return Err(BoxError::CacheError(format!(
510                "Failed to inspect cache destination {}: {error}",
511                dest_dir.display()
512            )))
513        }
514    }
515    let staging = tempfile::Builder::new()
516        .prefix(".staging-")
517        .tempdir_in(staging_parent)
518        .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
519    // copy_dir_recursive create_dir_all's its destination, so copy into a fresh
520    // subpath of the (already-existing) staging dir, then rename that subpath.
521    let staged = staging.path().join("d");
522    copy_dir_recursive(source_dir, &staged)?;
523
524    match std::fs::rename(&staged, dest_dir) {
525        Ok(()) => Ok(true),
526        // Lost the race: a concurrent put populated dest_dir first. Same key ⇒
527        // identical content, so keep the winner (staging auto-removes on drop).
528        Err(_) if super::is_real_directory(dest_dir) => Ok(false),
529        Err(e) => Err(BoxError::CacheError(format!(
530            "Failed to publish cache entry {}: {e}",
531            dest_dir.display()
532        ))),
533    }
534}
535
536/// Atomically write `json` to `meta_path` (unique temp + rename), so a
537/// concurrent reader never sees a half-written metadata file.
538pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
539    let parent = meta_path.parent().ok_or_else(|| {
540        BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
541    })?;
542    let _lock = crate::file_lock::FileLock::acquire(meta_path).map_err(|e| {
543        BoxError::CacheError(format!(
544            "Failed to lock cache metadata {}: {e}",
545            meta_path.display()
546        ))
547    })?;
548    let mut tmp = tempfile::NamedTempFile::new_in(parent)
549        .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
550    use std::io::Write as _;
551    tmp.write_all(json.as_bytes())
552        .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
553    persist_meta_file(tmp, meta_path)
554}
555
556#[cfg(windows)]
557fn persist_meta_file(mut tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
558    use std::time::Duration;
559
560    const ERROR_ACCESS_DENIED: i32 = 5;
561    const ERROR_SHARING_VIOLATION: i32 = 32;
562    const ERROR_LOCK_VIOLATION: i32 = 33;
563    const MAX_RETRIES: usize = 200;
564
565    let mut attempts = 0;
566    loop {
567        match tmp.persist(meta_path) {
568            Ok(_) => return Ok(()),
569            Err(error)
570                if attempts < MAX_RETRIES
571                    && matches!(
572                        error.error.raw_os_error(),
573                        Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
574                    ) =>
575            {
576                tmp = error.file;
577                attempts += 1;
578                std::thread::sleep(Duration::from_millis(10));
579            }
580            Err(error) => {
581                return Err(BoxError::CacheError(format!(
582                    "Failed to persist metadata: {error}"
583                )));
584            }
585        }
586    }
587}
588
589#[cfg(not(windows))]
590fn persist_meta_file(tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
591    tmp.persist(meta_path)
592        .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
593    Ok(())
594}
595
596/// Calculate the total size of a directory recursively.
597pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
598    let metadata = match std::fs::symlink_metadata(path) {
599        Ok(metadata) => metadata,
600        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
601        Err(error) => return Err(error),
602    };
603    if metadata.file_type().is_symlink() || metadata.is_file() {
604        return Ok(metadata.len());
605    }
606    if !metadata.is_dir() {
607        return Ok(0);
608    }
609
610    let mut total = 0_u64;
611    for entry in std::fs::read_dir(path)? {
612        total = total.saturating_add(dir_size(&entry?.path())?);
613    }
614    Ok(total)
615}
616
617impl CacheBackend for LayerCache {
618    fn get(&self, key: &str) -> Result<Option<PathBuf>> {
619        self.get(key)
620    }
621
622    fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
623        self.put(key, source_dir)
624    }
625
626    fn invalidate(&self, key: &str) -> Result<()> {
627        self.invalidate(key)
628    }
629
630    fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
631        self.prune(max_bytes)
632    }
633
634    fn list(&self) -> Result<Vec<CacheEntry>> {
635        self.list_entries().map(|entries| {
636            entries
637                .into_iter()
638                .map(|m| CacheEntry {
639                    key: m.digest,
640                    description: String::new(),
641                    size_bytes: m.size_bytes,
642                    cached_at: m.cached_at,
643                    last_accessed: m.last_accessed,
644                })
645                .collect()
646        })
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use tempfile::TempDir;
654
655    fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
656        std::fs::create_dir_all(dir).unwrap();
657        for (name, content) in files {
658            let file_path = dir.join(name);
659            if let Some(parent) = file_path.parent() {
660                std::fs::create_dir_all(parent).unwrap();
661            }
662            std::fs::write(&file_path, content).unwrap();
663        }
664    }
665
666    #[test]
667    fn test_layer_cache_new_creates_directory() {
668        let tmp = TempDir::new().unwrap();
669        let cache_dir = tmp.path().join("layers");
670
671        assert!(!cache_dir.exists());
672        let _cache = LayerCache::new(&cache_dir).unwrap();
673        assert!(cache_dir.is_dir());
674    }
675
676    #[test]
677    fn test_layer_cache_get_miss() {
678        let tmp = TempDir::new().unwrap();
679        let cache = LayerCache::new(tmp.path()).unwrap();
680
681        let result = cache.get("sha256:nonexistent").unwrap();
682        assert!(result.is_none());
683    }
684
685    #[test]
686    fn test_layer_cache_put_and_get() {
687        let tmp = TempDir::new().unwrap();
688        let cache = LayerCache::new(tmp.path()).unwrap();
689
690        // Create a source layer directory
691        let source = tmp.path().join("source_layer");
692        create_test_layer(
693            &source,
694            &[("file.txt", "hello"), ("sub/nested.txt", "world")],
695        );
696
697        // Put into cache
698        let digest = "sha256:abc123def456";
699        let cached_path = cache.put(digest, &source).unwrap();
700
701        assert!(cached_path.is_dir());
702        assert!(cached_path.join("file.txt").is_file());
703        assert!(cached_path.join("sub/nested.txt").is_file());
704
705        // Get from cache
706        let result = cache.get(digest).unwrap();
707        assert!(result.is_some());
708        assert_eq!(result.unwrap(), cached_path);
709    }
710
711    #[test]
712    fn test_layer_cache_put_same_digest_is_idempotent() {
713        // A layer digest IS the hash of its content, so the same digest can only
714        // ever map to identical content — re-putting it must be a no-op that
715        // keeps the first entry, not a remove-and-recopy (which corrupts the
716        // cache when two pulls of the same layer race). The "different content"
717        // below is an impossible-in-reality stand-in to prove the first write wins.
718        let tmp = TempDir::new().unwrap();
719        let cache = LayerCache::new(tmp.path()).unwrap();
720        let digest = "sha256:idempotent_test";
721
722        let source1 = tmp.path().join("v1");
723        create_test_layer(&source1, &[("v1.txt", "version 1")]);
724        let first = cache.put(digest, &source1).unwrap();
725
726        let source2 = tmp.path().join("v2");
727        create_test_layer(&source2, &[("v2.txt", "version 2")]);
728        let second = cache.put(digest, &source2).unwrap();
729
730        // Same cache path, first content preserved (idempotent, no overwrite).
731        assert_eq!(first, second);
732        assert!(second.join("v1.txt").is_file());
733        assert!(!second.join("v2.txt").exists());
734    }
735
736    #[test]
737    fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
738        use std::sync::Arc;
739
740        let tmp = TempDir::new().unwrap();
741        let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
742        let digest = "sha256:concurrent_test";
743
744        // Several identical source layers (like the same layer extracted by N
745        // racing pulls), each with the same multi-file content.
746        let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
747        let handles: Vec<_> = (0..12)
748            .map(|i| {
749                let cache = Arc::clone(&cache);
750                let src = tmp.path().join(format!("src{i}"));
751                create_test_layer(&src, files);
752                std::thread::spawn(move || cache.put(digest, &src).unwrap())
753            })
754            .collect();
755        let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
756
757        // Every put returns the same cache dir, and it is COMPLETE (no half-copied
758        // / interleaved layer): all files present with the right content.
759        for p in &paths {
760            assert_eq!(p, &paths[0]);
761            assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
762            assert_eq!(
763                std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
764                "beta"
765            );
766        }
767        assert!(cache.get(digest).unwrap().is_some());
768    }
769
770    #[cfg(windows)]
771    #[test]
772    fn test_write_meta_atomically_retries_windows_sharing_conflict() {
773        use std::os::windows::fs::OpenOptionsExt;
774        use std::time::{Duration, Instant};
775
776        let tmp = TempDir::new().unwrap();
777        let meta_path = tmp.path().join("entry.meta.json");
778        std::fs::write(&meta_path, r#"{"state":"old"}"#).unwrap();
779
780        let blocker = std::fs::OpenOptions::new()
781            .read(true)
782            .share_mode(0)
783            .open(&meta_path)
784            .unwrap();
785        let writer_path = meta_path.clone();
786        let writer =
787            std::thread::spawn(move || write_meta_atomically(&writer_path, r#"{"state":"new"}"#));
788
789        let deadline = Instant::now() + Duration::from_secs(1);
790        let staged_file_seen = loop {
791            let entry_count = std::fs::read_dir(tmp.path()).unwrap().count();
792            if entry_count >= 3 {
793                break true;
794            }
795            if writer.is_finished() || Instant::now() >= deadline {
796                break false;
797            }
798            std::thread::sleep(Duration::from_millis(5));
799        };
800        assert!(
801            staged_file_seen,
802            "metadata writer should stage a temporary file before retrying"
803        );
804        assert!(
805            !writer.is_finished(),
806            "metadata writer should wait for the Windows sharing conflict"
807        );
808
809        drop(blocker);
810        writer.join().unwrap().unwrap();
811        assert_eq!(
812            std::fs::read_to_string(meta_path).unwrap(),
813            r#"{"state":"new"}"#
814        );
815    }
816
817    #[test]
818    fn test_layer_cache_invalidate() {
819        let tmp = TempDir::new().unwrap();
820        let cache = LayerCache::new(tmp.path()).unwrap();
821        let digest = "sha256:to_invalidate";
822
823        let source = tmp.path().join("source");
824        create_test_layer(&source, &[("data.bin", "binary data")]);
825        cache.put(digest, &source).unwrap();
826
827        // Verify it exists
828        assert!(cache.get(digest).unwrap().is_some());
829
830        // Invalidate
831        cache.invalidate(digest).unwrap();
832
833        // Should be gone
834        assert!(cache.get(digest).unwrap().is_none());
835    }
836
837    #[test]
838    fn test_layer_cache_invalidate_nonexistent() {
839        let tmp = TempDir::new().unwrap();
840        let cache = LayerCache::new(tmp.path()).unwrap();
841
842        // Should not error on nonexistent digest
843        cache.invalidate("sha256:does_not_exist").unwrap();
844    }
845
846    #[test]
847    fn test_layer_cache_list_entries() {
848        let tmp = TempDir::new().unwrap();
849        let cache = LayerCache::new(tmp.path()).unwrap();
850
851        // Empty cache
852        assert_eq!(cache.list_entries().unwrap().len(), 0);
853
854        // Add two layers
855        let s1 = tmp.path().join("s1");
856        create_test_layer(&s1, &[("a.txt", "aaa")]);
857        cache.put("sha256:layer1", &s1).unwrap();
858
859        let s2 = tmp.path().join("s2");
860        create_test_layer(&s2, &[("b.txt", "bbb")]);
861        cache.put("sha256:layer2", &s2).unwrap();
862
863        let entries = cache.list_entries().unwrap();
864        assert_eq!(entries.len(), 2);
865
866        let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
867        assert!(digests.contains(&"sha256:layer1"));
868        assert!(digests.contains(&"sha256:layer2"));
869    }
870
871    #[test]
872    fn test_layer_cache_total_size() {
873        let tmp = TempDir::new().unwrap();
874        let cache = LayerCache::new(tmp.path()).unwrap();
875
876        assert_eq!(cache.total_size().unwrap(), 0);
877
878        let source = tmp.path().join("source");
879        create_test_layer(&source, &[("data.txt", "hello world")]);
880        cache.put("sha256:sized", &source).unwrap();
881
882        let total = cache.total_size().unwrap();
883        assert!(total > 0);
884    }
885
886    #[test]
887    fn test_layer_cache_prune_under_limit() {
888        let tmp = TempDir::new().unwrap();
889        let cache = LayerCache::new(tmp.path()).unwrap();
890
891        let source = tmp.path().join("source");
892        create_test_layer(&source, &[("small.txt", "tiny")]);
893        cache.put("sha256:small", &source).unwrap();
894
895        // Prune with a large limit — nothing should be evicted
896        let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
897        assert_eq!(evicted, 0);
898        assert!(cache.get("sha256:small").unwrap().is_some());
899    }
900
901    #[test]
902    fn test_layer_cache_prune_evicts_oldest() {
903        let tmp = TempDir::new().unwrap();
904        let cache = LayerCache::new(tmp.path()).unwrap();
905
906        // Add three layers with different access times
907        for i in 0..3 {
908            let source = tmp.path().join(format!("s{}", i));
909            // Create a file with enough content to matter
910            create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
911            cache.put(&format!("sha256:layer{}", i), &source).unwrap();
912            // Small delay to ensure different timestamps
913            std::thread::sleep(std::time::Duration::from_millis(10));
914        }
915
916        // Access layer2 to make it most recently used
917        cache.get("sha256:layer2").unwrap();
918
919        // Prune to a very small limit — should evict oldest first
920        let evicted = cache.prune(1).unwrap();
921        assert!(evicted >= 2);
922
923        // layer2 was most recently accessed, so it should survive longest
924        // (though with limit=1 byte, all may be evicted)
925    }
926
927    #[test]
928    fn test_layer_cache_metadata_persists() {
929        let tmp = TempDir::new().unwrap();
930        let cache = LayerCache::new(tmp.path()).unwrap();
931        let digest = "sha256:meta_test";
932
933        let source = tmp.path().join("source");
934        create_test_layer(&source, &[("file.txt", "content")]);
935        cache.put(digest, &source).unwrap();
936
937        // Read metadata directly
938        let meta_path = tmp.path().join("sha256_meta_test.meta.json");
939        assert!(meta_path.is_file());
940
941        let content = std::fs::read_to_string(&meta_path).unwrap();
942        let meta: LayerMeta = serde_json::from_str(&content).unwrap();
943
944        assert_eq!(meta.digest, digest);
945        assert!(meta.size_bytes > 0);
946        assert!(meta.cached_at > 0);
947        assert_eq!(meta.cached_at, meta.last_accessed);
948    }
949
950    #[test]
951    fn test_digest_to_dirname() {
952        assert_eq!(
953            LayerCache::digest_to_dirname("sha256:abc123"),
954            "sha256_abc123"
955        );
956        assert_eq!(
957            LayerCache::digest_to_dirname("plain_digest"),
958            "plain_digest"
959        );
960    }
961
962    #[test]
963    fn test_copy_dir_recursive() {
964        let tmp = TempDir::new().unwrap();
965        let src = tmp.path().join("src");
966        let dst = tmp.path().join("dst");
967
968        create_test_layer(
969            &src,
970            &[
971                ("a.txt", "aaa"),
972                ("sub/b.txt", "bbb"),
973                ("sub/deep/c.txt", "ccc"),
974            ],
975        );
976
977        copy_dir_recursive(&src, &dst).unwrap();
978
979        assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
980        assert_eq!(
981            std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
982            "bbb"
983        );
984        assert_eq!(
985            std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
986            "ccc"
987        );
988    }
989
990    #[test]
991    fn test_copy_dir_recursive_copies_contents_into_existing_destination() {
992        let tmp = TempDir::new().unwrap();
993        let src = tmp.path().join("rootfs");
994        let dst = tmp.path().join("existing");
995
996        create_test_layer(&src, &[("bin/sh", "shell"), ("etc/config", "value")]);
997        std::fs::create_dir_all(&dst).unwrap();
998        std::fs::write(dst.join("marker"), "keep").unwrap();
999
1000        copy_dir_recursive(&src, &dst).unwrap();
1001
1002        assert_eq!(
1003            std::fs::read_to_string(dst.join("bin/sh")).unwrap(),
1004            "shell"
1005        );
1006        assert_eq!(
1007            std::fs::read_to_string(dst.join("etc/config")).unwrap(),
1008            "value"
1009        );
1010        assert_eq!(std::fs::read_to_string(dst.join("marker")).unwrap(), "keep");
1011        assert!(!dst.join("rootfs").exists());
1012    }
1013
1014    #[cfg(unix)]
1015    #[test]
1016    fn test_copy_dir_recursive_preserves_symlinks() {
1017        let tmp = TempDir::new().unwrap();
1018        let src = tmp.path().join("src");
1019        let dst = tmp.path().join("dst");
1020        std::fs::create_dir_all(&src).unwrap();
1021        std::fs::write(src.join("target.txt"), "target").unwrap();
1022        std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
1023
1024        copy_dir_recursive(&src, &dst).unwrap();
1025
1026        let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
1027        assert!(link_meta.file_type().is_symlink());
1028        assert_eq!(
1029            std::fs::read_link(dst.join("link.txt")).unwrap(),
1030            std::path::PathBuf::from("target.txt")
1031        );
1032    }
1033
1034    #[cfg(target_os = "windows")]
1035    #[test]
1036    fn test_copy_dir_recursive_preserves_windows_symlink() {
1037        let tmp = TempDir::new().unwrap();
1038        let src = tmp.path().join("src");
1039        let dst = tmp.path().join("dst");
1040        std::fs::create_dir_all(&src).unwrap();
1041        std::fs::write(src.join("target.txt"), "target").unwrap();
1042        if let Err(error) = std::os::windows::fs::symlink_file("target.txt", src.join("link.txt")) {
1043            // Windows requires Developer Mode or SeCreateSymbolicLinkPrivilege.
1044            // Keep exercising the copy when the host supports symlinks, while
1045            // allowing unprivileged Windows CI and developer shells to proceed.
1046            const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
1047            if error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) {
1048                return;
1049            }
1050            panic!("failed to create Windows test symlink: {error}");
1051        }
1052
1053        copy_dir_recursive(&src, &dst).unwrap();
1054
1055        let copied_link = dst.join("link.txt");
1056        assert!(std::fs::symlink_metadata(&copied_link)
1057            .unwrap()
1058            .file_type()
1059            .is_symlink());
1060        assert_eq!(
1061            std::fs::read_link(&copied_link).unwrap(),
1062            Path::new("target.txt")
1063        );
1064        assert_eq!(std::fs::read_to_string(copied_link).unwrap(), "target");
1065    }
1066
1067    #[test]
1068    fn test_dir_size() {
1069        let tmp = TempDir::new().unwrap();
1070        let dir = tmp.path().join("sized");
1071        create_test_layer(
1072            &dir,
1073            &[
1074                ("a.txt", "hello"),     // 5 bytes
1075                ("sub/b.txt", "world"), // 5 bytes
1076            ],
1077        );
1078
1079        let size = dir_size(&dir).unwrap();
1080        assert_eq!(size, 10);
1081    }
1082
1083    #[cfg(unix)]
1084    #[test]
1085    fn test_dir_size_does_not_follow_external_directory_symlink() {
1086        let tmp = TempDir::new().unwrap();
1087        let dir = tmp.path().join("sized");
1088        let outside = tmp.path().join("outside");
1089        std::fs::create_dir_all(&dir).unwrap();
1090        std::fs::create_dir_all(&outside).unwrap();
1091        std::fs::write(dir.join("local"), b"local").unwrap();
1092        std::fs::write(outside.join("host-data"), vec![0_u8; 4096]).unwrap();
1093        let link = dir.join("external");
1094        std::os::unix::fs::symlink(&outside, &link).unwrap();
1095
1096        let size = dir_size(&dir).unwrap();
1097
1098        assert_eq!(
1099            size,
1100            5 + std::fs::symlink_metadata(link).unwrap().len(),
1101            "cache sizing must count the symlink itself without entering its target"
1102        );
1103    }
1104
1105    #[cfg(unix)]
1106    #[test]
1107    fn test_dir_size_does_not_follow_symlink_cycle() {
1108        let tmp = TempDir::new().unwrap();
1109        let dir = tmp.path().join("sized");
1110        std::fs::create_dir_all(&dir).unwrap();
1111        let link = dir.join("loop");
1112        std::os::unix::fs::symlink(".", &link).unwrap();
1113
1114        let size = dir_size(&dir).unwrap();
1115
1116        assert_eq!(size, std::fs::symlink_metadata(link).unwrap().len());
1117    }
1118
1119    #[test]
1120    fn test_dir_size_empty_directory() {
1121        let tmp = TempDir::new().unwrap();
1122        let dir = tmp.path().join("empty");
1123        std::fs::create_dir_all(&dir).unwrap();
1124
1125        let size = dir_size(&dir).unwrap();
1126        assert_eq!(size, 0);
1127    }
1128
1129    #[test]
1130    fn test_dir_size_nonexistent_returns_zero() {
1131        let tmp = TempDir::new().unwrap();
1132        let dir = tmp.path().join("nonexistent");
1133
1134        // Not a directory, so returns 0
1135        let size = dir_size(&dir).unwrap();
1136        assert_eq!(size, 0);
1137    }
1138
1139    #[test]
1140    fn test_copy_dir_recursive_empty_directory() {
1141        let tmp = TempDir::new().unwrap();
1142        let src = tmp.path().join("empty_src");
1143        let dst = tmp.path().join("empty_dst");
1144        std::fs::create_dir_all(&src).unwrap();
1145
1146        copy_dir_recursive(&src, &dst).unwrap();
1147        assert!(dst.is_dir());
1148    }
1149
1150    #[test]
1151    fn test_copy_dir_recursive_source_not_exists() {
1152        let tmp = TempDir::new().unwrap();
1153        let src = tmp.path().join("nonexistent");
1154        let dst = tmp.path().join("dst");
1155
1156        let result = copy_dir_recursive(&src, &dst);
1157        assert!(result.is_err());
1158    }
1159
1160    #[test]
1161    fn test_layer_cache_get_updates_last_accessed() {
1162        let tmp = TempDir::new().unwrap();
1163        let cache = LayerCache::new(tmp.path()).unwrap();
1164        let digest = "sha256:access_test";
1165
1166        let source = tmp.path().join("source");
1167        create_test_layer(&source, &[("f.txt", "data")]);
1168        cache.put(digest, &source).unwrap();
1169
1170        // Read initial metadata
1171        let meta_path = tmp.path().join("sha256_access_test.meta.json");
1172        let content = std::fs::read_to_string(&meta_path).unwrap();
1173        let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
1174
1175        // Small delay to ensure timestamp difference
1176        std::thread::sleep(std::time::Duration::from_millis(10));
1177
1178        // Access the cache entry
1179        cache.get(digest).unwrap();
1180
1181        // Read updated metadata
1182        let content = std::fs::read_to_string(&meta_path).unwrap();
1183        let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
1184
1185        assert!(meta_after.last_accessed >= meta_before.last_accessed);
1186        // cached_at should not change
1187        assert_eq!(meta_after.cached_at, meta_before.cached_at);
1188    }
1189
1190    #[test]
1191    fn test_layer_cache_get_corrupted_metadata() {
1192        let tmp = TempDir::new().unwrap();
1193        let cache = LayerCache::new(tmp.path()).unwrap();
1194        let digest = "sha256:corrupted";
1195        let safe_name = LayerCache::digest_to_dirname(digest);
1196
1197        // Create layer directory manually
1198        let layer_dir = tmp.path().join(&safe_name);
1199        std::fs::create_dir_all(&layer_dir).unwrap();
1200
1201        // Write corrupted metadata
1202        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1203        std::fs::write(&meta_path, "not valid json!!!").unwrap();
1204
1205        // get() should still return Some (directory exists, metadata is best-effort)
1206        // The directory exists and meta file exists, so it returns the path
1207        let result = cache.get(digest).unwrap();
1208        assert!(result.is_some());
1209    }
1210
1211    #[test]
1212    fn test_layer_cache_get_directory_without_metadata() {
1213        let tmp = TempDir::new().unwrap();
1214        let cache = LayerCache::new(tmp.path()).unwrap();
1215        let digest = "sha256:no_meta";
1216        let safe_name = LayerCache::digest_to_dirname(digest);
1217
1218        // Create layer directory but no metadata file
1219        let layer_dir = tmp.path().join(&safe_name);
1220        std::fs::create_dir_all(&layer_dir).unwrap();
1221
1222        // Should return None (metadata missing)
1223        let result = cache.get(digest).unwrap();
1224        assert!(result.is_none());
1225    }
1226
1227    #[test]
1228    fn test_layer_cache_get_metadata_without_directory() {
1229        let tmp = TempDir::new().unwrap();
1230        let cache = LayerCache::new(tmp.path()).unwrap();
1231        let digest = "sha256:no_dir";
1232        let safe_name = LayerCache::digest_to_dirname(digest);
1233
1234        // Create metadata file but no layer directory
1235        let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1236        let meta = LayerMeta {
1237            digest: digest.to_string(),
1238            size_bytes: 0,
1239            cached_at: 0,
1240            last_accessed: 0,
1241        };
1242        std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1243
1244        // Should return None (directory missing)
1245        let result = cache.get(digest).unwrap();
1246        assert!(result.is_none());
1247    }
1248
1249    #[test]
1250    fn test_layer_cache_put_source_not_exists() {
1251        let tmp = TempDir::new().unwrap();
1252        let cache = LayerCache::new(tmp.path()).unwrap();
1253
1254        let nonexistent = tmp.path().join("does_not_exist");
1255        let result = cache.put("sha256:bad_source", &nonexistent);
1256        assert!(result.is_err());
1257    }
1258
1259    #[test]
1260    fn test_layer_cache_rejects_path_traversal_keys() {
1261        let tmp = TempDir::new().unwrap();
1262        let cache = LayerCache::new(tmp.path()).unwrap();
1263        let source = tmp.path().join("source");
1264        create_test_layer(&source, &[("file", "content")]);
1265
1266        for digest in ["../outside", "nested/layer", "nested\\layer", "..", ""] {
1267            assert!(cache.get(digest).is_err(), "digest={digest:?}");
1268            assert!(cache.put(digest, &source).is_err(), "digest={digest:?}");
1269            assert!(cache.invalidate(digest).is_err(), "digest={digest:?}");
1270        }
1271    }
1272
1273    #[cfg(unix)]
1274    #[test]
1275    fn test_layer_cache_does_not_follow_symlink_entries() {
1276        let tmp = TempDir::new().unwrap();
1277        let cache = LayerCache::new(tmp.path()).unwrap();
1278        let outside = tmp.path().join("outside");
1279        create_test_layer(&outside, &[("host-data", "must survive")]);
1280        let digest = "sha256:linked";
1281        let link = tmp.path().join(LayerCache::digest_to_dirname(digest));
1282        std::os::unix::fs::symlink(&outside, &link).unwrap();
1283
1284        assert!(cache.get(digest).unwrap().is_none());
1285        let source = tmp.path().join("source");
1286        create_test_layer(&source, &[("file", "content")]);
1287        assert!(cache.put(digest, &source).is_err());
1288
1289        cache.invalidate(digest).unwrap();
1290        assert!(outside.join("host-data").is_file());
1291        assert!(!link.exists());
1292    }
1293
1294    #[test]
1295    fn test_layer_cache_prune_zero_limit() {
1296        let tmp = TempDir::new().unwrap();
1297        let cache = LayerCache::new(tmp.path()).unwrap();
1298
1299        let source = tmp.path().join("source");
1300        create_test_layer(&source, &[("f.txt", "data")]);
1301        cache.put("sha256:entry1", &source).unwrap();
1302        cache.put("sha256:entry2", &source).unwrap();
1303
1304        // Prune with 0 bytes limit — should evict everything
1305        let evicted = cache.prune(0).unwrap();
1306        assert_eq!(evicted, 2);
1307        assert_eq!(cache.list_entries().unwrap().len(), 0);
1308    }
1309
1310    #[test]
1311    fn test_layer_cache_list_entries_ignores_non_meta_files() {
1312        let tmp = TempDir::new().unwrap();
1313        let cache = LayerCache::new(tmp.path()).unwrap();
1314
1315        // Add a valid entry
1316        let source = tmp.path().join("source");
1317        create_test_layer(&source, &[("f.txt", "data")]);
1318        cache.put("sha256:valid", &source).unwrap();
1319
1320        // Add random non-meta files to cache directory
1321        std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1322        std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1323
1324        // Should only return the valid entry
1325        let entries = cache.list_entries().unwrap();
1326        assert_eq!(entries.len(), 1);
1327        assert_eq!(entries[0].digest, "sha256:valid");
1328    }
1329
1330    #[test]
1331    fn test_layer_cache_list_entries_skips_invalid_json() {
1332        let tmp = TempDir::new().unwrap();
1333        let cache = LayerCache::new(tmp.path()).unwrap();
1334
1335        // Add a valid entry
1336        let source = tmp.path().join("source");
1337        create_test_layer(&source, &[("f.txt", "data")]);
1338        cache.put("sha256:valid", &source).unwrap();
1339
1340        // Add a corrupted .meta.json
1341        std::fs::write(
1342            tmp.path().join("sha256_corrupted.meta.json"),
1343            "not json at all",
1344        )
1345        .unwrap();
1346
1347        // Should only return the valid entry, skip corrupted
1348        let entries = cache.list_entries().unwrap();
1349        assert_eq!(entries.len(), 1);
1350        assert_eq!(entries[0].digest, "sha256:valid");
1351    }
1352
1353    #[test]
1354    fn test_layer_cache_put_preserves_file_content() {
1355        let tmp = TempDir::new().unwrap();
1356        let cache = LayerCache::new(tmp.path()).unwrap();
1357
1358        let source = tmp.path().join("source");
1359        create_test_layer(
1360            &source,
1361            &[
1362                ("binary.bin", "\x00\x01\x02\x03"),
1363                ("text.txt", "hello world\n"),
1364            ],
1365        );
1366
1367        let cached = cache.put("sha256:content_check", &source).unwrap();
1368
1369        assert_eq!(
1370            std::fs::read(cached.join("binary.bin")).unwrap(),
1371            b"\x00\x01\x02\x03"
1372        );
1373        assert_eq!(
1374            std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1375            "hello world\n"
1376        );
1377    }
1378
1379    #[test]
1380    fn test_layer_cache_multiple_colons_in_digest() {
1381        let tmp = TempDir::new().unwrap();
1382        let cache = LayerCache::new(tmp.path()).unwrap();
1383
1384        let digest = "sha256:abc:def:ghi";
1385        let source = tmp.path().join("source");
1386        create_test_layer(&source, &[("f.txt", "data")]);
1387
1388        cache.put(digest, &source).unwrap();
1389        let result = cache.get(digest).unwrap();
1390        assert!(result.is_some());
1391
1392        cache.invalidate(digest).unwrap();
1393        assert!(cache.get(digest).unwrap().is_none());
1394    }
1395
1396    #[test]
1397    fn test_copy_file_cow_preserves_content_and_mode() {
1398        // Works whether the FS supports reflink (FICLONE) or falls back to a byte
1399        // copy — both must preserve content and the permission bits.
1400        let tmp = TempDir::new().unwrap();
1401        let src = tmp.path().join("src.bin");
1402        let dst = tmp.path().join("dst.bin");
1403        std::fs::write(&src, b"hello copy-on-write").unwrap();
1404        #[cfg(unix)]
1405        {
1406            use std::os::unix::fs::PermissionsExt;
1407            std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1408        }
1409
1410        copy_file_cow(&src, &dst).unwrap();
1411
1412        assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1413        #[cfg(unix)]
1414        {
1415            use std::os::unix::fs::PermissionsExt;
1416            let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1417            assert_eq!(mode, 0o755, "executable bit must survive the copy");
1418        }
1419    }
1420
1421    #[cfg(unix)]
1422    #[test]
1423    fn test_copy_dir_recursive_preserves_directory_modes() {
1424        use std::os::unix::fs::PermissionsExt;
1425
1426        let tmp = TempDir::new().unwrap();
1427        let src = tmp.path().join("src");
1428        let nested = src.join("nested");
1429        let dst = tmp.path().join("dst");
1430        std::fs::create_dir_all(&nested).unwrap();
1431        std::fs::write(nested.join("file"), b"content").unwrap();
1432        std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1433        std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o710)).unwrap();
1434
1435        copy_dir_recursive(&src, &dst).unwrap();
1436
1437        let root_mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1438        let nested_mode = std::fs::metadata(dst.join("nested"))
1439            .unwrap()
1440            .permissions()
1441            .mode()
1442            & 0o777;
1443        assert_eq!(root_mode, 0o755);
1444        assert_eq!(nested_mode, 0o710);
1445    }
1446
1447    #[test]
1448    fn test_copy_file_cow_overwrites_existing_dst() {
1449        // FICLONE and the fs::copy fallback both truncate the destination.
1450        let tmp = TempDir::new().unwrap();
1451        let src = tmp.path().join("src");
1452        let dst = tmp.path().join("dst");
1453        std::fs::write(&src, b"new").unwrap();
1454        std::fs::write(&dst, b"old-and-longer").unwrap();
1455        copy_file_cow(&src, &dst).unwrap();
1456        assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1457    }
1458}