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