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