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 super::validate_cache_key(digest, "layer digest")?;
55 let safe_name = Self::digest_to_dirname(digest);
56 let layer_dir = self.cache_dir.join(&safe_name);
57 let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
58
59 if !super::is_real_directory(&layer_dir) || !super::is_regular_file(&meta_path) {
60 return Ok(None);
61 }
62
63 if let Ok(content) = std::fs::read_to_string(&meta_path) {
65 if let Ok(mut meta) = serde_json::from_str::<LayerMeta>(&content) {
66 meta.last_accessed = chrono::Utc::now().timestamp();
67 if let Err(e) =
68 write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)
69 {
70 tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update layer cache metadata");
71 }
72 }
73 }
74
75 Ok(Some(layer_dir))
76 }
77
78 pub fn put(&self, digest: &str, source_dir: &Path) -> Result<PathBuf> {
83 super::validate_cache_key(digest, "layer digest")?;
84 let safe_name = Self::digest_to_dirname(digest);
85 let layer_dir = self.cache_dir.join(&safe_name);
86 let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
87
88 if super::is_real_directory(&layer_dir) && super::is_regular_file(&meta_path) {
91 return Ok(layer_dir);
92 }
93
94 publish_dir_atomically(source_dir, &layer_dir, &self.cache_dir)?;
98
99 let size_bytes = dir_size(&layer_dir).unwrap_or(0);
101
102 let now = chrono::Utc::now().timestamp();
104 let meta = LayerMeta {
105 digest: digest.to_string(),
106 size_bytes,
107 cached_at: now,
108 last_accessed: now,
109 };
110 write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)?;
111
112 tracing::debug!(
113 digest = %digest,
114 size_bytes,
115 path = %layer_dir.display(),
116 "Cached OCI layer"
117 );
118
119 Ok(layer_dir)
120 }
121
122 pub fn invalidate(&self, digest: &str) -> Result<()> {
124 super::validate_cache_key(digest, "layer digest")?;
125 let safe_name = Self::digest_to_dirname(digest);
126 let layer_dir = self.cache_dir.join(&safe_name);
127 let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
128
129 super::remove_path_no_follow(&layer_dir)?;
130 super::remove_path_no_follow(&meta_path)?;
131
132 Ok(())
133 }
134
135 pub fn prune(&self, max_bytes: u64) -> Result<usize> {
140 let mut entries = self.list_entries()?;
141
142 let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
144 if total_size <= max_bytes {
145 return Ok(0);
146 }
147
148 entries.sort_by_key(|e| e.last_accessed);
150
151 let mut current_size = total_size;
152 let mut evicted = 0;
153
154 for entry in &entries {
155 if current_size <= max_bytes {
156 break;
157 }
158 self.invalidate(&entry.digest)?;
159 current_size = current_size.saturating_sub(entry.size_bytes);
160 evicted += 1;
161
162 tracing::debug!(
163 digest = %entry.digest,
164 size_bytes = entry.size_bytes,
165 "Evicted cached layer"
166 );
167 }
168
169 Ok(evicted)
170 }
171
172 pub fn list_entries(&self) -> Result<Vec<LayerMeta>> {
174 let mut entries = Vec::new();
175
176 let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
177 BoxError::CacheError(format!(
178 "Failed to read cache directory {}: {}",
179 self.cache_dir.display(),
180 e
181 ))
182 })?;
183
184 for entry in read_dir {
185 let entry = entry.map_err(|e| {
186 BoxError::CacheError(format!("Failed to read directory entry: {}", e))
187 })?;
188 let path = entry.path();
189
190 if path.extension().and_then(|e| e.to_str()) == Some("json") {
192 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
193 if name.ends_with(".meta.json") {
194 if let Ok(content) = std::fs::read_to_string(&path) {
195 if let Ok(meta) = serde_json::from_str::<LayerMeta>(&content) {
196 entries.push(meta);
197 }
198 }
199 }
200 }
201 }
202 }
203
204 Ok(entries)
205 }
206
207 pub fn total_size(&self) -> Result<u64> {
209 Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
210 }
211
212 fn digest_to_dirname(digest: &str) -> String {
217 digest.replace(':', "_")
218 }
219}
220
221#[cfg(unix)]
229fn preserve_owner(meta: &std::fs::Metadata, dst: &Path) {
230 use std::os::unix::ffi::OsStrExt;
231 use std::os::unix::fs::MetadataExt;
232 if unsafe { libc::geteuid() } != 0 {
233 return;
234 }
235 if let Ok(c_path) = std::ffi::CString::new(dst.as_os_str().as_bytes()) {
236 unsafe {
238 libc::lchown(c_path.as_ptr(), meta.uid(), meta.gid());
239 }
240 }
241}
242
243#[cfg(not(unix))]
244fn preserve_owner(_meta: &std::fs::Metadata, _dst: &Path) {}
245
246pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
247 #[cfg(target_os = "macos")]
248 {
249 let dst_preexisted = dst.exists();
250 if !dst_preexisted && copy_dir_recursive_cow(src, dst).unwrap_or(false) {
258 return Ok(());
259 }
260 if !dst_preexisted && dst.exists() {
261 let _ = std::fs::remove_dir_all(dst);
262 }
263 }
264
265 std::fs::create_dir_all(dst).map_err(|e| {
266 BoxError::CacheError(format!(
267 "Failed to create directory {}: {}",
268 dst.display(),
269 e
270 ))
271 })?;
272 let src_meta = std::fs::symlink_metadata(src).map_err(|e| {
273 BoxError::CacheError(format!(
274 "Failed to read directory metadata for {}: {}",
275 src.display(),
276 e
277 ))
278 })?;
279 preserve_owner(&src_meta, dst);
281
282 for entry in std::fs::read_dir(src).map_err(|e| {
283 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
284 })? {
285 let entry = entry
286 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
287 let src_path = entry.path();
288 let dst_path = dst.join(entry.file_name());
289
290 let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
292 BoxError::CacheError(format!(
293 "Failed to read metadata for {}: {}",
294 src_path.display(),
295 e
296 ))
297 })?;
298
299 if meta.file_type().is_symlink() {
300 let target = std::fs::read_link(&src_path).map_err(|e| {
301 BoxError::CacheError(format!(
302 "Failed to read symlink {}: {}",
303 src_path.display(),
304 e
305 ))
306 })?;
307 #[cfg(unix)]
308 {
309 std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
310 BoxError::CacheError(format!(
311 "Failed to create symlink {} -> {}: {}",
312 dst_path.display(),
313 target.display(),
314 e
315 ))
316 })?;
317 preserve_owner(&meta, &dst_path);
318 }
319 #[cfg(windows)]
320 {
321 use std::os::windows::fs::MetadataExt;
322
323 const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
327 let result = if meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
328 std::os::windows::fs::symlink_dir(&target, &dst_path)
329 } else {
330 std::os::windows::fs::symlink_file(&target, &dst_path)
331 };
332 result.map_err(|e| {
333 BoxError::CacheError(format!(
334 "Failed to create symlink {} -> {}: {}",
335 dst_path.display(),
336 target.display(),
337 e
338 ))
339 })?;
340 }
341 #[cfg(not(any(unix, windows)))]
342 {
343 return Err(BoxError::CacheError(format!(
344 "Symlink copy is not supported on this platform: {}",
345 src_path.display()
346 )));
347 }
348 } else if meta.is_dir() {
349 copy_dir_recursive(&src_path, &dst_path)?;
350 } else {
351 copy_file_cow(&src_path, &dst_path).map_err(|e| {
352 BoxError::CacheError(format!(
353 "Failed to copy {} to {}: {}",
354 src_path.display(),
355 dst_path.display(),
356 e
357 ))
358 })?;
359 preserve_owner(&meta, &dst_path);
360 }
361 }
362
363 std::fs::set_permissions(dst, src_meta.permissions()).map_err(|e| {
367 BoxError::CacheError(format!(
368 "Failed to preserve directory permissions on {}: {}",
369 dst.display(),
370 e
371 ))
372 })?;
373
374 Ok(())
375}
376
377#[cfg(target_os = "macos")]
378fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
379 use std::os::unix::ffi::OsStrExt;
380
381 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
382 std::io::Error::new(
383 std::io::ErrorKind::InvalidInput,
384 "source path contains a NUL byte",
385 )
386 })?;
387 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
388 std::io::Error::new(
389 std::io::ErrorKind::InvalidInput,
390 "destination path contains a NUL byte",
391 )
392 })?;
393 let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
394 let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
398 Ok(rc == 0)
399}
400
401pub(crate) fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
408 #[cfg(target_os = "linux")]
409 {
410 use std::os::unix::io::AsRawFd;
411 let reflinked = (|| -> std::io::Result<bool> {
412 let s = std::fs::File::open(src)?;
413 let d = std::fs::OpenOptions::new()
414 .write(true)
415 .create(true)
416 .truncate(true)
417 .open(dst)?;
418 let rc = unsafe { libc::ioctl(d.as_raw_fd(), libc::FICLONE as _, s.as_raw_fd()) };
425 if rc != 0 {
426 return Ok(false);
427 }
428 if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
430 let _ = d.set_permissions(perm);
431 }
432 Ok(true)
433 })()
434 .unwrap_or(false);
435 if reflinked {
436 return Ok(());
437 }
438 }
439
440 #[cfg(target_os = "macos")]
441 {
442 use std::os::unix::ffi::OsStrExt;
443
444 let cloned = (|| -> std::io::Result<bool> {
445 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
446 std::io::Error::new(
447 std::io::ErrorKind::InvalidInput,
448 "source path contains a NUL byte",
449 )
450 })?;
451 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
452 std::io::Error::new(
453 std::io::ErrorKind::InvalidInput,
454 "destination path contains a NUL byte",
455 )
456 })?;
457
458 let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
462 if rc != 0 {
463 return Ok(false);
464 }
465 if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
466 let _ = std::fs::set_permissions(dst, perm);
467 }
468 Ok(true)
469 })()
470 .unwrap_or(false);
471 if cloned {
472 return Ok(());
473 }
474 }
475
476 std::fs::copy(src, dst).map(|_| ())
477}
478
479pub(crate) fn publish_dir_atomically(
493 source_dir: &Path,
494 dest_dir: &Path,
495 staging_parent: &Path,
496) -> Result<bool> {
497 match std::fs::symlink_metadata(dest_dir) {
498 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
499 return Ok(false)
500 }
501 Ok(_) => {
502 return Err(BoxError::CacheError(format!(
503 "Cache destination is not a real directory: {}",
504 dest_dir.display()
505 )))
506 }
507 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
508 Err(error) => {
509 return Err(BoxError::CacheError(format!(
510 "Failed to inspect cache destination {}: {error}",
511 dest_dir.display()
512 )))
513 }
514 }
515 let staging = tempfile::Builder::new()
516 .prefix(".staging-")
517 .tempdir_in(staging_parent)
518 .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
519 let staged = staging.path().join("d");
522 copy_dir_recursive(source_dir, &staged)?;
523
524 match std::fs::rename(&staged, dest_dir) {
525 Ok(()) => Ok(true),
526 Err(_) if super::is_real_directory(dest_dir) => Ok(false),
529 Err(e) => Err(BoxError::CacheError(format!(
530 "Failed to publish cache entry {}: {e}",
531 dest_dir.display()
532 ))),
533 }
534}
535
536pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
539 let parent = meta_path.parent().ok_or_else(|| {
540 BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
541 })?;
542 let _lock = crate::file_lock::FileLock::acquire(meta_path).map_err(|e| {
543 BoxError::CacheError(format!(
544 "Failed to lock cache metadata {}: {e}",
545 meta_path.display()
546 ))
547 })?;
548 let mut tmp = tempfile::NamedTempFile::new_in(parent)
549 .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
550 use std::io::Write as _;
551 tmp.write_all(json.as_bytes())
552 .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
553 persist_meta_file(tmp, meta_path)
554}
555
556#[cfg(windows)]
557fn persist_meta_file(mut tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
558 use std::time::Duration;
559
560 const ERROR_ACCESS_DENIED: i32 = 5;
561 const ERROR_SHARING_VIOLATION: i32 = 32;
562 const ERROR_LOCK_VIOLATION: i32 = 33;
563 const MAX_RETRIES: usize = 200;
564
565 let mut attempts = 0;
566 loop {
567 match tmp.persist(meta_path) {
568 Ok(_) => return Ok(()),
569 Err(error)
570 if attempts < MAX_RETRIES
571 && matches!(
572 error.error.raw_os_error(),
573 Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
574 ) =>
575 {
576 tmp = error.file;
577 attempts += 1;
578 std::thread::sleep(Duration::from_millis(10));
579 }
580 Err(error) => {
581 return Err(BoxError::CacheError(format!(
582 "Failed to persist metadata: {error}"
583 )));
584 }
585 }
586 }
587}
588
589#[cfg(not(windows))]
590fn persist_meta_file(tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
591 tmp.persist(meta_path)
592 .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
593 Ok(())
594}
595
596pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
598 let metadata = match std::fs::symlink_metadata(path) {
599 Ok(metadata) => metadata,
600 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
601 Err(error) => return Err(error),
602 };
603 if metadata.file_type().is_symlink() || metadata.is_file() {
604 return Ok(metadata.len());
605 }
606 if !metadata.is_dir() {
607 return Ok(0);
608 }
609
610 let mut total = 0_u64;
611 for entry in std::fs::read_dir(path)? {
612 total = total.saturating_add(dir_size(&entry?.path())?);
613 }
614 Ok(total)
615}
616
617impl CacheBackend for LayerCache {
618 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
619 self.get(key)
620 }
621
622 fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
623 self.put(key, source_dir)
624 }
625
626 fn invalidate(&self, key: &str) -> Result<()> {
627 self.invalidate(key)
628 }
629
630 fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
631 self.prune(max_bytes)
632 }
633
634 fn list(&self) -> Result<Vec<CacheEntry>> {
635 self.list_entries().map(|entries| {
636 entries
637 .into_iter()
638 .map(|m| CacheEntry {
639 key: m.digest,
640 description: String::new(),
641 size_bytes: m.size_bytes,
642 cached_at: m.cached_at,
643 last_accessed: m.last_accessed,
644 })
645 .collect()
646 })
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use tempfile::TempDir;
654
655 fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
656 std::fs::create_dir_all(dir).unwrap();
657 for (name, content) in files {
658 let file_path = dir.join(name);
659 if let Some(parent) = file_path.parent() {
660 std::fs::create_dir_all(parent).unwrap();
661 }
662 std::fs::write(&file_path, content).unwrap();
663 }
664 }
665
666 #[test]
667 fn test_layer_cache_new_creates_directory() {
668 let tmp = TempDir::new().unwrap();
669 let cache_dir = tmp.path().join("layers");
670
671 assert!(!cache_dir.exists());
672 let _cache = LayerCache::new(&cache_dir).unwrap();
673 assert!(cache_dir.is_dir());
674 }
675
676 #[test]
677 fn test_layer_cache_get_miss() {
678 let tmp = TempDir::new().unwrap();
679 let cache = LayerCache::new(tmp.path()).unwrap();
680
681 let result = cache.get("sha256:nonexistent").unwrap();
682 assert!(result.is_none());
683 }
684
685 #[test]
686 fn test_layer_cache_put_and_get() {
687 let tmp = TempDir::new().unwrap();
688 let cache = LayerCache::new(tmp.path()).unwrap();
689
690 let source = tmp.path().join("source_layer");
692 create_test_layer(
693 &source,
694 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
695 );
696
697 let digest = "sha256:abc123def456";
699 let cached_path = cache.put(digest, &source).unwrap();
700
701 assert!(cached_path.is_dir());
702 assert!(cached_path.join("file.txt").is_file());
703 assert!(cached_path.join("sub/nested.txt").is_file());
704
705 let result = cache.get(digest).unwrap();
707 assert!(result.is_some());
708 assert_eq!(result.unwrap(), cached_path);
709 }
710
711 #[test]
712 fn test_layer_cache_put_same_digest_is_idempotent() {
713 let tmp = TempDir::new().unwrap();
719 let cache = LayerCache::new(tmp.path()).unwrap();
720 let digest = "sha256:idempotent_test";
721
722 let source1 = tmp.path().join("v1");
723 create_test_layer(&source1, &[("v1.txt", "version 1")]);
724 let first = cache.put(digest, &source1).unwrap();
725
726 let source2 = tmp.path().join("v2");
727 create_test_layer(&source2, &[("v2.txt", "version 2")]);
728 let second = cache.put(digest, &source2).unwrap();
729
730 assert_eq!(first, second);
732 assert!(second.join("v1.txt").is_file());
733 assert!(!second.join("v2.txt").exists());
734 }
735
736 #[test]
737 fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
738 use std::sync::Arc;
739
740 let tmp = TempDir::new().unwrap();
741 let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
742 let digest = "sha256:concurrent_test";
743
744 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
747 let handles: Vec<_> = (0..12)
748 .map(|i| {
749 let cache = Arc::clone(&cache);
750 let src = tmp.path().join(format!("src{i}"));
751 create_test_layer(&src, files);
752 std::thread::spawn(move || cache.put(digest, &src).unwrap())
753 })
754 .collect();
755 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
756
757 for p in &paths {
760 assert_eq!(p, &paths[0]);
761 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
762 assert_eq!(
763 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
764 "beta"
765 );
766 }
767 assert!(cache.get(digest).unwrap().is_some());
768 }
769
770 #[cfg(windows)]
771 #[test]
772 fn test_write_meta_atomically_retries_windows_sharing_conflict() {
773 use std::os::windows::fs::OpenOptionsExt;
774 use std::time::{Duration, Instant};
775
776 let tmp = TempDir::new().unwrap();
777 let meta_path = tmp.path().join("entry.meta.json");
778 std::fs::write(&meta_path, r#"{"state":"old"}"#).unwrap();
779
780 let blocker = std::fs::OpenOptions::new()
781 .read(true)
782 .share_mode(0)
783 .open(&meta_path)
784 .unwrap();
785 let writer_path = meta_path.clone();
786 let writer =
787 std::thread::spawn(move || write_meta_atomically(&writer_path, r#"{"state":"new"}"#));
788
789 let deadline = Instant::now() + Duration::from_secs(1);
790 let staged_file_seen = loop {
791 let entry_count = std::fs::read_dir(tmp.path()).unwrap().count();
792 if entry_count >= 3 {
793 break true;
794 }
795 if writer.is_finished() || Instant::now() >= deadline {
796 break false;
797 }
798 std::thread::sleep(Duration::from_millis(5));
799 };
800 assert!(
801 staged_file_seen,
802 "metadata writer should stage a temporary file before retrying"
803 );
804 assert!(
805 !writer.is_finished(),
806 "metadata writer should wait for the Windows sharing conflict"
807 );
808
809 drop(blocker);
810 writer.join().unwrap().unwrap();
811 assert_eq!(
812 std::fs::read_to_string(meta_path).unwrap(),
813 r#"{"state":"new"}"#
814 );
815 }
816
817 #[test]
818 fn test_layer_cache_invalidate() {
819 let tmp = TempDir::new().unwrap();
820 let cache = LayerCache::new(tmp.path()).unwrap();
821 let digest = "sha256:to_invalidate";
822
823 let source = tmp.path().join("source");
824 create_test_layer(&source, &[("data.bin", "binary data")]);
825 cache.put(digest, &source).unwrap();
826
827 assert!(cache.get(digest).unwrap().is_some());
829
830 cache.invalidate(digest).unwrap();
832
833 assert!(cache.get(digest).unwrap().is_none());
835 }
836
837 #[test]
838 fn test_layer_cache_invalidate_nonexistent() {
839 let tmp = TempDir::new().unwrap();
840 let cache = LayerCache::new(tmp.path()).unwrap();
841
842 cache.invalidate("sha256:does_not_exist").unwrap();
844 }
845
846 #[test]
847 fn test_layer_cache_list_entries() {
848 let tmp = TempDir::new().unwrap();
849 let cache = LayerCache::new(tmp.path()).unwrap();
850
851 assert_eq!(cache.list_entries().unwrap().len(), 0);
853
854 let s1 = tmp.path().join("s1");
856 create_test_layer(&s1, &[("a.txt", "aaa")]);
857 cache.put("sha256:layer1", &s1).unwrap();
858
859 let s2 = tmp.path().join("s2");
860 create_test_layer(&s2, &[("b.txt", "bbb")]);
861 cache.put("sha256:layer2", &s2).unwrap();
862
863 let entries = cache.list_entries().unwrap();
864 assert_eq!(entries.len(), 2);
865
866 let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
867 assert!(digests.contains(&"sha256:layer1"));
868 assert!(digests.contains(&"sha256:layer2"));
869 }
870
871 #[test]
872 fn test_layer_cache_total_size() {
873 let tmp = TempDir::new().unwrap();
874 let cache = LayerCache::new(tmp.path()).unwrap();
875
876 assert_eq!(cache.total_size().unwrap(), 0);
877
878 let source = tmp.path().join("source");
879 create_test_layer(&source, &[("data.txt", "hello world")]);
880 cache.put("sha256:sized", &source).unwrap();
881
882 let total = cache.total_size().unwrap();
883 assert!(total > 0);
884 }
885
886 #[test]
887 fn test_layer_cache_prune_under_limit() {
888 let tmp = TempDir::new().unwrap();
889 let cache = LayerCache::new(tmp.path()).unwrap();
890
891 let source = tmp.path().join("source");
892 create_test_layer(&source, &[("small.txt", "tiny")]);
893 cache.put("sha256:small", &source).unwrap();
894
895 let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
897 assert_eq!(evicted, 0);
898 assert!(cache.get("sha256:small").unwrap().is_some());
899 }
900
901 #[test]
902 fn test_layer_cache_prune_evicts_oldest() {
903 let tmp = TempDir::new().unwrap();
904 let cache = LayerCache::new(tmp.path()).unwrap();
905
906 for i in 0..3 {
908 let source = tmp.path().join(format!("s{}", i));
909 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
911 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
912 std::thread::sleep(std::time::Duration::from_millis(10));
914 }
915
916 cache.get("sha256:layer2").unwrap();
918
919 let evicted = cache.prune(1).unwrap();
921 assert!(evicted >= 2);
922
923 }
926
927 #[test]
928 fn test_layer_cache_metadata_persists() {
929 let tmp = TempDir::new().unwrap();
930 let cache = LayerCache::new(tmp.path()).unwrap();
931 let digest = "sha256:meta_test";
932
933 let source = tmp.path().join("source");
934 create_test_layer(&source, &[("file.txt", "content")]);
935 cache.put(digest, &source).unwrap();
936
937 let meta_path = tmp.path().join("sha256_meta_test.meta.json");
939 assert!(meta_path.is_file());
940
941 let content = std::fs::read_to_string(&meta_path).unwrap();
942 let meta: LayerMeta = serde_json::from_str(&content).unwrap();
943
944 assert_eq!(meta.digest, digest);
945 assert!(meta.size_bytes > 0);
946 assert!(meta.cached_at > 0);
947 assert_eq!(meta.cached_at, meta.last_accessed);
948 }
949
950 #[test]
951 fn test_digest_to_dirname() {
952 assert_eq!(
953 LayerCache::digest_to_dirname("sha256:abc123"),
954 "sha256_abc123"
955 );
956 assert_eq!(
957 LayerCache::digest_to_dirname("plain_digest"),
958 "plain_digest"
959 );
960 }
961
962 #[test]
963 fn test_copy_dir_recursive() {
964 let tmp = TempDir::new().unwrap();
965 let src = tmp.path().join("src");
966 let dst = tmp.path().join("dst");
967
968 create_test_layer(
969 &src,
970 &[
971 ("a.txt", "aaa"),
972 ("sub/b.txt", "bbb"),
973 ("sub/deep/c.txt", "ccc"),
974 ],
975 );
976
977 copy_dir_recursive(&src, &dst).unwrap();
978
979 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
980 assert_eq!(
981 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
982 "bbb"
983 );
984 assert_eq!(
985 std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
986 "ccc"
987 );
988 }
989
990 #[test]
991 fn test_copy_dir_recursive_copies_contents_into_existing_destination() {
992 let tmp = TempDir::new().unwrap();
993 let src = tmp.path().join("rootfs");
994 let dst = tmp.path().join("existing");
995
996 create_test_layer(&src, &[("bin/sh", "shell"), ("etc/config", "value")]);
997 std::fs::create_dir_all(&dst).unwrap();
998 std::fs::write(dst.join("marker"), "keep").unwrap();
999
1000 copy_dir_recursive(&src, &dst).unwrap();
1001
1002 assert_eq!(
1003 std::fs::read_to_string(dst.join("bin/sh")).unwrap(),
1004 "shell"
1005 );
1006 assert_eq!(
1007 std::fs::read_to_string(dst.join("etc/config")).unwrap(),
1008 "value"
1009 );
1010 assert_eq!(std::fs::read_to_string(dst.join("marker")).unwrap(), "keep");
1011 assert!(!dst.join("rootfs").exists());
1012 }
1013
1014 #[cfg(unix)]
1015 #[test]
1016 fn test_copy_dir_recursive_preserves_symlinks() {
1017 let tmp = TempDir::new().unwrap();
1018 let src = tmp.path().join("src");
1019 let dst = tmp.path().join("dst");
1020 std::fs::create_dir_all(&src).unwrap();
1021 std::fs::write(src.join("target.txt"), "target").unwrap();
1022 std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
1023
1024 copy_dir_recursive(&src, &dst).unwrap();
1025
1026 let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
1027 assert!(link_meta.file_type().is_symlink());
1028 assert_eq!(
1029 std::fs::read_link(dst.join("link.txt")).unwrap(),
1030 std::path::PathBuf::from("target.txt")
1031 );
1032 }
1033
1034 #[cfg(target_os = "windows")]
1035 #[test]
1036 fn test_copy_dir_recursive_preserves_windows_symlink() {
1037 let tmp = TempDir::new().unwrap();
1038 let src = tmp.path().join("src");
1039 let dst = tmp.path().join("dst");
1040 std::fs::create_dir_all(&src).unwrap();
1041 std::fs::write(src.join("target.txt"), "target").unwrap();
1042 if let Err(error) = std::os::windows::fs::symlink_file("target.txt", src.join("link.txt")) {
1043 const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
1047 if error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) {
1048 return;
1049 }
1050 panic!("failed to create Windows test symlink: {error}");
1051 }
1052
1053 copy_dir_recursive(&src, &dst).unwrap();
1054
1055 let copied_link = dst.join("link.txt");
1056 assert!(std::fs::symlink_metadata(&copied_link)
1057 .unwrap()
1058 .file_type()
1059 .is_symlink());
1060 assert_eq!(
1061 std::fs::read_link(&copied_link).unwrap(),
1062 Path::new("target.txt")
1063 );
1064 assert_eq!(std::fs::read_to_string(copied_link).unwrap(), "target");
1065 }
1066
1067 #[test]
1068 fn test_dir_size() {
1069 let tmp = TempDir::new().unwrap();
1070 let dir = tmp.path().join("sized");
1071 create_test_layer(
1072 &dir,
1073 &[
1074 ("a.txt", "hello"), ("sub/b.txt", "world"), ],
1077 );
1078
1079 let size = dir_size(&dir).unwrap();
1080 assert_eq!(size, 10);
1081 }
1082
1083 #[cfg(unix)]
1084 #[test]
1085 fn test_dir_size_does_not_follow_external_directory_symlink() {
1086 let tmp = TempDir::new().unwrap();
1087 let dir = tmp.path().join("sized");
1088 let outside = tmp.path().join("outside");
1089 std::fs::create_dir_all(&dir).unwrap();
1090 std::fs::create_dir_all(&outside).unwrap();
1091 std::fs::write(dir.join("local"), b"local").unwrap();
1092 std::fs::write(outside.join("host-data"), vec![0_u8; 4096]).unwrap();
1093 let link = dir.join("external");
1094 std::os::unix::fs::symlink(&outside, &link).unwrap();
1095
1096 let size = dir_size(&dir).unwrap();
1097
1098 assert_eq!(
1099 size,
1100 5 + std::fs::symlink_metadata(link).unwrap().len(),
1101 "cache sizing must count the symlink itself without entering its target"
1102 );
1103 }
1104
1105 #[cfg(unix)]
1106 #[test]
1107 fn test_dir_size_does_not_follow_symlink_cycle() {
1108 let tmp = TempDir::new().unwrap();
1109 let dir = tmp.path().join("sized");
1110 std::fs::create_dir_all(&dir).unwrap();
1111 let link = dir.join("loop");
1112 std::os::unix::fs::symlink(".", &link).unwrap();
1113
1114 let size = dir_size(&dir).unwrap();
1115
1116 assert_eq!(size, std::fs::symlink_metadata(link).unwrap().len());
1117 }
1118
1119 #[test]
1120 fn test_dir_size_empty_directory() {
1121 let tmp = TempDir::new().unwrap();
1122 let dir = tmp.path().join("empty");
1123 std::fs::create_dir_all(&dir).unwrap();
1124
1125 let size = dir_size(&dir).unwrap();
1126 assert_eq!(size, 0);
1127 }
1128
1129 #[test]
1130 fn test_dir_size_nonexistent_returns_zero() {
1131 let tmp = TempDir::new().unwrap();
1132 let dir = tmp.path().join("nonexistent");
1133
1134 let size = dir_size(&dir).unwrap();
1136 assert_eq!(size, 0);
1137 }
1138
1139 #[test]
1140 fn test_copy_dir_recursive_empty_directory() {
1141 let tmp = TempDir::new().unwrap();
1142 let src = tmp.path().join("empty_src");
1143 let dst = tmp.path().join("empty_dst");
1144 std::fs::create_dir_all(&src).unwrap();
1145
1146 copy_dir_recursive(&src, &dst).unwrap();
1147 assert!(dst.is_dir());
1148 }
1149
1150 #[test]
1151 fn test_copy_dir_recursive_source_not_exists() {
1152 let tmp = TempDir::new().unwrap();
1153 let src = tmp.path().join("nonexistent");
1154 let dst = tmp.path().join("dst");
1155
1156 let result = copy_dir_recursive(&src, &dst);
1157 assert!(result.is_err());
1158 }
1159
1160 #[test]
1161 fn test_layer_cache_get_updates_last_accessed() {
1162 let tmp = TempDir::new().unwrap();
1163 let cache = LayerCache::new(tmp.path()).unwrap();
1164 let digest = "sha256:access_test";
1165
1166 let source = tmp.path().join("source");
1167 create_test_layer(&source, &[("f.txt", "data")]);
1168 cache.put(digest, &source).unwrap();
1169
1170 let meta_path = tmp.path().join("sha256_access_test.meta.json");
1172 let content = std::fs::read_to_string(&meta_path).unwrap();
1173 let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
1174
1175 std::thread::sleep(std::time::Duration::from_millis(10));
1177
1178 cache.get(digest).unwrap();
1180
1181 let content = std::fs::read_to_string(&meta_path).unwrap();
1183 let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
1184
1185 assert!(meta_after.last_accessed >= meta_before.last_accessed);
1186 assert_eq!(meta_after.cached_at, meta_before.cached_at);
1188 }
1189
1190 #[test]
1191 fn test_layer_cache_get_corrupted_metadata() {
1192 let tmp = TempDir::new().unwrap();
1193 let cache = LayerCache::new(tmp.path()).unwrap();
1194 let digest = "sha256:corrupted";
1195 let safe_name = LayerCache::digest_to_dirname(digest);
1196
1197 let layer_dir = tmp.path().join(&safe_name);
1199 std::fs::create_dir_all(&layer_dir).unwrap();
1200
1201 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1203 std::fs::write(&meta_path, "not valid json!!!").unwrap();
1204
1205 let result = cache.get(digest).unwrap();
1208 assert!(result.is_some());
1209 }
1210
1211 #[test]
1212 fn test_layer_cache_get_directory_without_metadata() {
1213 let tmp = TempDir::new().unwrap();
1214 let cache = LayerCache::new(tmp.path()).unwrap();
1215 let digest = "sha256:no_meta";
1216 let safe_name = LayerCache::digest_to_dirname(digest);
1217
1218 let layer_dir = tmp.path().join(&safe_name);
1220 std::fs::create_dir_all(&layer_dir).unwrap();
1221
1222 let result = cache.get(digest).unwrap();
1224 assert!(result.is_none());
1225 }
1226
1227 #[test]
1228 fn test_layer_cache_get_metadata_without_directory() {
1229 let tmp = TempDir::new().unwrap();
1230 let cache = LayerCache::new(tmp.path()).unwrap();
1231 let digest = "sha256:no_dir";
1232 let safe_name = LayerCache::digest_to_dirname(digest);
1233
1234 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1236 let meta = LayerMeta {
1237 digest: digest.to_string(),
1238 size_bytes: 0,
1239 cached_at: 0,
1240 last_accessed: 0,
1241 };
1242 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1243
1244 let result = cache.get(digest).unwrap();
1246 assert!(result.is_none());
1247 }
1248
1249 #[test]
1250 fn test_layer_cache_put_source_not_exists() {
1251 let tmp = TempDir::new().unwrap();
1252 let cache = LayerCache::new(tmp.path()).unwrap();
1253
1254 let nonexistent = tmp.path().join("does_not_exist");
1255 let result = cache.put("sha256:bad_source", &nonexistent);
1256 assert!(result.is_err());
1257 }
1258
1259 #[test]
1260 fn test_layer_cache_rejects_path_traversal_keys() {
1261 let tmp = TempDir::new().unwrap();
1262 let cache = LayerCache::new(tmp.path()).unwrap();
1263 let source = tmp.path().join("source");
1264 create_test_layer(&source, &[("file", "content")]);
1265
1266 for digest in ["../outside", "nested/layer", "nested\\layer", "..", ""] {
1267 assert!(cache.get(digest).is_err(), "digest={digest:?}");
1268 assert!(cache.put(digest, &source).is_err(), "digest={digest:?}");
1269 assert!(cache.invalidate(digest).is_err(), "digest={digest:?}");
1270 }
1271 }
1272
1273 #[cfg(unix)]
1274 #[test]
1275 fn test_layer_cache_does_not_follow_symlink_entries() {
1276 let tmp = TempDir::new().unwrap();
1277 let cache = LayerCache::new(tmp.path()).unwrap();
1278 let outside = tmp.path().join("outside");
1279 create_test_layer(&outside, &[("host-data", "must survive")]);
1280 let digest = "sha256:linked";
1281 let link = tmp.path().join(LayerCache::digest_to_dirname(digest));
1282 std::os::unix::fs::symlink(&outside, &link).unwrap();
1283
1284 assert!(cache.get(digest).unwrap().is_none());
1285 let source = tmp.path().join("source");
1286 create_test_layer(&source, &[("file", "content")]);
1287 assert!(cache.put(digest, &source).is_err());
1288
1289 cache.invalidate(digest).unwrap();
1290 assert!(outside.join("host-data").is_file());
1291 assert!(!link.exists());
1292 }
1293
1294 #[test]
1295 fn test_layer_cache_prune_zero_limit() {
1296 let tmp = TempDir::new().unwrap();
1297 let cache = LayerCache::new(tmp.path()).unwrap();
1298
1299 let source = tmp.path().join("source");
1300 create_test_layer(&source, &[("f.txt", "data")]);
1301 cache.put("sha256:entry1", &source).unwrap();
1302 cache.put("sha256:entry2", &source).unwrap();
1303
1304 let evicted = cache.prune(0).unwrap();
1306 assert_eq!(evicted, 2);
1307 assert_eq!(cache.list_entries().unwrap().len(), 0);
1308 }
1309
1310 #[test]
1311 fn test_layer_cache_list_entries_ignores_non_meta_files() {
1312 let tmp = TempDir::new().unwrap();
1313 let cache = LayerCache::new(tmp.path()).unwrap();
1314
1315 let source = tmp.path().join("source");
1317 create_test_layer(&source, &[("f.txt", "data")]);
1318 cache.put("sha256:valid", &source).unwrap();
1319
1320 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1322 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1323
1324 let entries = cache.list_entries().unwrap();
1326 assert_eq!(entries.len(), 1);
1327 assert_eq!(entries[0].digest, "sha256:valid");
1328 }
1329
1330 #[test]
1331 fn test_layer_cache_list_entries_skips_invalid_json() {
1332 let tmp = TempDir::new().unwrap();
1333 let cache = LayerCache::new(tmp.path()).unwrap();
1334
1335 let source = tmp.path().join("source");
1337 create_test_layer(&source, &[("f.txt", "data")]);
1338 cache.put("sha256:valid", &source).unwrap();
1339
1340 std::fs::write(
1342 tmp.path().join("sha256_corrupted.meta.json"),
1343 "not json at all",
1344 )
1345 .unwrap();
1346
1347 let entries = cache.list_entries().unwrap();
1349 assert_eq!(entries.len(), 1);
1350 assert_eq!(entries[0].digest, "sha256:valid");
1351 }
1352
1353 #[test]
1354 fn test_layer_cache_put_preserves_file_content() {
1355 let tmp = TempDir::new().unwrap();
1356 let cache = LayerCache::new(tmp.path()).unwrap();
1357
1358 let source = tmp.path().join("source");
1359 create_test_layer(
1360 &source,
1361 &[
1362 ("binary.bin", "\x00\x01\x02\x03"),
1363 ("text.txt", "hello world\n"),
1364 ],
1365 );
1366
1367 let cached = cache.put("sha256:content_check", &source).unwrap();
1368
1369 assert_eq!(
1370 std::fs::read(cached.join("binary.bin")).unwrap(),
1371 b"\x00\x01\x02\x03"
1372 );
1373 assert_eq!(
1374 std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1375 "hello world\n"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_layer_cache_multiple_colons_in_digest() {
1381 let tmp = TempDir::new().unwrap();
1382 let cache = LayerCache::new(tmp.path()).unwrap();
1383
1384 let digest = "sha256:abc:def:ghi";
1385 let source = tmp.path().join("source");
1386 create_test_layer(&source, &[("f.txt", "data")]);
1387
1388 cache.put(digest, &source).unwrap();
1389 let result = cache.get(digest).unwrap();
1390 assert!(result.is_some());
1391
1392 cache.invalidate(digest).unwrap();
1393 assert!(cache.get(digest).unwrap().is_none());
1394 }
1395
1396 #[test]
1397 fn test_copy_file_cow_preserves_content_and_mode() {
1398 let tmp = TempDir::new().unwrap();
1401 let src = tmp.path().join("src.bin");
1402 let dst = tmp.path().join("dst.bin");
1403 std::fs::write(&src, b"hello copy-on-write").unwrap();
1404 #[cfg(unix)]
1405 {
1406 use std::os::unix::fs::PermissionsExt;
1407 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1408 }
1409
1410 copy_file_cow(&src, &dst).unwrap();
1411
1412 assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1413 #[cfg(unix)]
1414 {
1415 use std::os::unix::fs::PermissionsExt;
1416 let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1417 assert_eq!(mode, 0o755, "executable bit must survive the copy");
1418 }
1419 }
1420
1421 #[cfg(unix)]
1422 #[test]
1423 fn test_copy_dir_recursive_preserves_directory_modes() {
1424 use std::os::unix::fs::PermissionsExt;
1425
1426 let tmp = TempDir::new().unwrap();
1427 let src = tmp.path().join("src");
1428 let nested = src.join("nested");
1429 let dst = tmp.path().join("dst");
1430 std::fs::create_dir_all(&nested).unwrap();
1431 std::fs::write(nested.join("file"), b"content").unwrap();
1432 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1433 std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o710)).unwrap();
1434
1435 copy_dir_recursive(&src, &dst).unwrap();
1436
1437 let root_mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1438 let nested_mode = std::fs::metadata(dst.join("nested"))
1439 .unwrap()
1440 .permissions()
1441 .mode()
1442 & 0o777;
1443 assert_eq!(root_mode, 0o755);
1444 assert_eq!(nested_mode, 0o710);
1445 }
1446
1447 #[test]
1448 fn test_copy_file_cow_overwrites_existing_dst() {
1449 let tmp = TempDir::new().unwrap();
1451 let src = tmp.path().join("src");
1452 let dst = tmp.path().join("dst");
1453 std::fs::write(&src, b"new").unwrap();
1454 std::fs::write(&dst, b"old-and-longer").unwrap();
1455 copy_file_cow(&src, &dst).unwrap();
1456 assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1457 }
1458}