1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LayerMeta {
15 pub digest: String,
17 pub size_bytes: u64,
19 pub cached_at: i64,
21 pub last_accessed: i64,
23}
24
25pub struct LayerCache {
30 cache_dir: PathBuf,
32}
33
34impl LayerCache {
35 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 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 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 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 if layer_dir.is_dir() && meta_path.is_file() {
89 return Ok(layer_dir);
90 }
91
92 publish_dir_atomically(source_dir, &layer_dir, &self.cache_dir)?;
96
97 let size_bytes = dir_size(&layer_dir).unwrap_or(0);
99
100 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 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 pub fn prune(&self, max_bytes: u64) -> Result<usize> {
153 let mut entries = self.list_entries()?;
154
155 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 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 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 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 pub fn total_size(&self) -> Result<u64> {
222 Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
223 }
224
225 fn digest_to_dirname(digest: &str) -> String {
230 digest.replace(':', "_")
231 }
232}
233
234#[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 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 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 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 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 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 let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
404 Ok(rc == 0)
405}
406
407pub(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 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 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 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
485pub(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 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 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
527pub(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
587pub(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 let source = tmp.path().join("source_layer");
683 create_test_layer(
684 &source,
685 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
686 );
687
688 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 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 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 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 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 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 assert!(cache.get(digest).unwrap().is_some());
820
821 cache.invalidate(digest).unwrap();
823
824 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 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 assert_eq!(cache.list_entries().unwrap().len(), 0);
844
845 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 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 for i in 0..3 {
899 let source = tmp.path().join(format!("s{}", i));
900 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
902 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
903 std::thread::sleep(std::time::Duration::from_millis(10));
905 }
906
907 cache.get("sha256:layer2").unwrap();
909
910 let evicted = cache.prune(1).unwrap();
912 assert!(evicted >= 2);
913
914 }
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 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 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"), ("sub/b.txt", "world"), ],
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 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 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 std::thread::sleep(std::time::Duration::from_millis(10));
1144
1145 cache.get(digest).unwrap();
1147
1148 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 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 let layer_dir = tmp.path().join(&safe_name);
1166 std::fs::create_dir_all(&layer_dir).unwrap();
1167
1168 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1170 std::fs::write(&meta_path, "not valid json!!!").unwrap();
1171
1172 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 let layer_dir = tmp.path().join(&safe_name);
1187 std::fs::create_dir_all(&layer_dir).unwrap();
1188
1189 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 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 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 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 let source = tmp.path().join("source");
1249 create_test_layer(&source, &[("f.txt", "data")]);
1250 cache.put("sha256:valid", &source).unwrap();
1251
1252 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1254 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1255
1256 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 let source = tmp.path().join("source");
1269 create_test_layer(&source, &[("f.txt", "data")]);
1270 cache.put("sha256:valid", &source).unwrap();
1271
1272 std::fs::write(
1274 tmp.path().join("sha256_corrupted.meta.json"),
1275 "not json at all",
1276 )
1277 .unwrap();
1278
1279 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 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 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}