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 !dst_preexisted && copy_dir_recursive_cow(src, dst).unwrap_or(false) {
271 return Ok(());
272 }
273 if !dst_preexisted && dst.exists() {
274 let _ = std::fs::remove_dir_all(dst);
275 }
276 }
277
278 std::fs::create_dir_all(dst).map_err(|e| {
279 BoxError::CacheError(format!(
280 "Failed to create directory {}: {}",
281 dst.display(),
282 e
283 ))
284 })?;
285 let src_meta = std::fs::symlink_metadata(src).map_err(|e| {
286 BoxError::CacheError(format!(
287 "Failed to read directory metadata for {}: {}",
288 src.display(),
289 e
290 ))
291 })?;
292 preserve_owner(&src_meta, dst);
294
295 for entry in std::fs::read_dir(src).map_err(|e| {
296 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
297 })? {
298 let entry = entry
299 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
300 let src_path = entry.path();
301 let dst_path = dst.join(entry.file_name());
302
303 let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
305 BoxError::CacheError(format!(
306 "Failed to read metadata for {}: {}",
307 src_path.display(),
308 e
309 ))
310 })?;
311
312 if meta.file_type().is_symlink() {
313 let target = std::fs::read_link(&src_path).map_err(|e| {
314 BoxError::CacheError(format!(
315 "Failed to read symlink {}: {}",
316 src_path.display(),
317 e
318 ))
319 })?;
320 #[cfg(unix)]
321 {
322 std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
323 BoxError::CacheError(format!(
324 "Failed to create symlink {} -> {}: {}",
325 dst_path.display(),
326 target.display(),
327 e
328 ))
329 })?;
330 preserve_owner(&meta, &dst_path);
331 }
332 #[cfg(windows)]
333 {
334 use std::os::windows::fs::MetadataExt;
335
336 const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
340 let result = if meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
341 std::os::windows::fs::symlink_dir(&target, &dst_path)
342 } else {
343 std::os::windows::fs::symlink_file(&target, &dst_path)
344 };
345 result.map_err(|e| {
346 BoxError::CacheError(format!(
347 "Failed to create symlink {} -> {}: {}",
348 dst_path.display(),
349 target.display(),
350 e
351 ))
352 })?;
353 }
354 #[cfg(not(any(unix, windows)))]
355 {
356 return Err(BoxError::CacheError(format!(
357 "Symlink copy is not supported on this platform: {}",
358 src_path.display()
359 )));
360 }
361 } else if meta.is_dir() {
362 copy_dir_recursive(&src_path, &dst_path)?;
363 } else {
364 copy_file_cow(&src_path, &dst_path).map_err(|e| {
365 BoxError::CacheError(format!(
366 "Failed to copy {} to {}: {}",
367 src_path.display(),
368 dst_path.display(),
369 e
370 ))
371 })?;
372 preserve_owner(&meta, &dst_path);
373 }
374 }
375
376 std::fs::set_permissions(dst, src_meta.permissions()).map_err(|e| {
380 BoxError::CacheError(format!(
381 "Failed to preserve directory permissions on {}: {}",
382 dst.display(),
383 e
384 ))
385 })?;
386
387 Ok(())
388}
389
390#[cfg(target_os = "macos")]
391fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
392 use std::os::unix::ffi::OsStrExt;
393
394 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
395 std::io::Error::new(
396 std::io::ErrorKind::InvalidInput,
397 "source path contains a NUL byte",
398 )
399 })?;
400 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
401 std::io::Error::new(
402 std::io::ErrorKind::InvalidInput,
403 "destination path contains a NUL byte",
404 )
405 })?;
406 let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
407 let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
411 Ok(rc == 0)
412}
413
414pub(crate) fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
421 #[cfg(target_os = "linux")]
422 {
423 use std::os::unix::io::AsRawFd;
424 let reflinked = (|| -> std::io::Result<bool> {
425 let s = std::fs::File::open(src)?;
426 let d = std::fs::OpenOptions::new()
427 .write(true)
428 .create(true)
429 .truncate(true)
430 .open(dst)?;
431 let rc = unsafe { libc::ioctl(d.as_raw_fd(), libc::FICLONE as _, s.as_raw_fd()) };
438 if rc != 0 {
439 return Ok(false);
440 }
441 if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
443 let _ = d.set_permissions(perm);
444 }
445 Ok(true)
446 })()
447 .unwrap_or(false);
448 if reflinked {
449 return Ok(());
450 }
451 }
452
453 #[cfg(target_os = "macos")]
454 {
455 use std::os::unix::ffi::OsStrExt;
456
457 let cloned = (|| -> std::io::Result<bool> {
458 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
459 std::io::Error::new(
460 std::io::ErrorKind::InvalidInput,
461 "source path contains a NUL byte",
462 )
463 })?;
464 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
465 std::io::Error::new(
466 std::io::ErrorKind::InvalidInput,
467 "destination path contains a NUL byte",
468 )
469 })?;
470
471 let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
475 if rc != 0 {
476 return Ok(false);
477 }
478 if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
479 let _ = std::fs::set_permissions(dst, perm);
480 }
481 Ok(true)
482 })()
483 .unwrap_or(false);
484 if cloned {
485 return Ok(());
486 }
487 }
488
489 std::fs::copy(src, dst).map(|_| ())
490}
491
492pub(crate) fn publish_dir_atomically(
506 source_dir: &Path,
507 dest_dir: &Path,
508 staging_parent: &Path,
509) -> Result<bool> {
510 if dest_dir.exists() {
511 return Ok(false);
512 }
513 let staging = tempfile::Builder::new()
514 .prefix(".staging-")
515 .tempdir_in(staging_parent)
516 .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
517 let staged = staging.path().join("d");
520 copy_dir_recursive(source_dir, &staged)?;
521
522 match std::fs::rename(&staged, dest_dir) {
523 Ok(()) => Ok(true),
524 Err(_) if dest_dir.exists() => Ok(false),
527 Err(e) => Err(BoxError::CacheError(format!(
528 "Failed to publish cache entry {}: {e}",
529 dest_dir.display()
530 ))),
531 }
532}
533
534pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
537 let parent = meta_path.parent().ok_or_else(|| {
538 BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
539 })?;
540 let _lock = crate::file_lock::FileLock::acquire(meta_path).map_err(|e| {
541 BoxError::CacheError(format!(
542 "Failed to lock cache metadata {}: {e}",
543 meta_path.display()
544 ))
545 })?;
546 let mut tmp = tempfile::NamedTempFile::new_in(parent)
547 .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
548 use std::io::Write as _;
549 tmp.write_all(json.as_bytes())
550 .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
551 persist_meta_file(tmp, meta_path)
552}
553
554#[cfg(windows)]
555fn persist_meta_file(mut tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
556 use std::time::Duration;
557
558 const ERROR_ACCESS_DENIED: i32 = 5;
559 const ERROR_SHARING_VIOLATION: i32 = 32;
560 const ERROR_LOCK_VIOLATION: i32 = 33;
561 const MAX_RETRIES: usize = 200;
562
563 let mut attempts = 0;
564 loop {
565 match tmp.persist(meta_path) {
566 Ok(_) => return Ok(()),
567 Err(error)
568 if attempts < MAX_RETRIES
569 && matches!(
570 error.error.raw_os_error(),
571 Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
572 ) =>
573 {
574 tmp = error.file;
575 attempts += 1;
576 std::thread::sleep(Duration::from_millis(10));
577 }
578 Err(error) => {
579 return Err(BoxError::CacheError(format!(
580 "Failed to persist metadata: {error}"
581 )));
582 }
583 }
584 }
585}
586
587#[cfg(not(windows))]
588fn persist_meta_file(tmp: tempfile::NamedTempFile, meta_path: &Path) -> Result<()> {
589 tmp.persist(meta_path)
590 .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
591 Ok(())
592}
593
594pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
596 let metadata = match std::fs::symlink_metadata(path) {
597 Ok(metadata) => metadata,
598 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
599 Err(error) => return Err(error),
600 };
601 if metadata.file_type().is_symlink() || metadata.is_file() {
602 return Ok(metadata.len());
603 }
604 if !metadata.is_dir() {
605 return Ok(0);
606 }
607
608 let mut total = 0_u64;
609 for entry in std::fs::read_dir(path)? {
610 total = total.saturating_add(dir_size(&entry?.path())?);
611 }
612 Ok(total)
613}
614
615impl CacheBackend for LayerCache {
616 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
617 self.get(key)
618 }
619
620 fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
621 self.put(key, source_dir)
622 }
623
624 fn invalidate(&self, key: &str) -> Result<()> {
625 self.invalidate(key)
626 }
627
628 fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
629 self.prune(max_bytes)
630 }
631
632 fn list(&self) -> Result<Vec<CacheEntry>> {
633 self.list_entries().map(|entries| {
634 entries
635 .into_iter()
636 .map(|m| CacheEntry {
637 key: m.digest,
638 description: String::new(),
639 size_bytes: m.size_bytes,
640 cached_at: m.cached_at,
641 last_accessed: m.last_accessed,
642 })
643 .collect()
644 })
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use tempfile::TempDir;
652
653 fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
654 std::fs::create_dir_all(dir).unwrap();
655 for (name, content) in files {
656 let file_path = dir.join(name);
657 if let Some(parent) = file_path.parent() {
658 std::fs::create_dir_all(parent).unwrap();
659 }
660 std::fs::write(&file_path, content).unwrap();
661 }
662 }
663
664 #[test]
665 fn test_layer_cache_new_creates_directory() {
666 let tmp = TempDir::new().unwrap();
667 let cache_dir = tmp.path().join("layers");
668
669 assert!(!cache_dir.exists());
670 let _cache = LayerCache::new(&cache_dir).unwrap();
671 assert!(cache_dir.is_dir());
672 }
673
674 #[test]
675 fn test_layer_cache_get_miss() {
676 let tmp = TempDir::new().unwrap();
677 let cache = LayerCache::new(tmp.path()).unwrap();
678
679 let result = cache.get("sha256:nonexistent").unwrap();
680 assert!(result.is_none());
681 }
682
683 #[test]
684 fn test_layer_cache_put_and_get() {
685 let tmp = TempDir::new().unwrap();
686 let cache = LayerCache::new(tmp.path()).unwrap();
687
688 let source = tmp.path().join("source_layer");
690 create_test_layer(
691 &source,
692 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
693 );
694
695 let digest = "sha256:abc123def456";
697 let cached_path = cache.put(digest, &source).unwrap();
698
699 assert!(cached_path.is_dir());
700 assert!(cached_path.join("file.txt").is_file());
701 assert!(cached_path.join("sub/nested.txt").is_file());
702
703 let result = cache.get(digest).unwrap();
705 assert!(result.is_some());
706 assert_eq!(result.unwrap(), cached_path);
707 }
708
709 #[test]
710 fn test_layer_cache_put_same_digest_is_idempotent() {
711 let tmp = TempDir::new().unwrap();
717 let cache = LayerCache::new(tmp.path()).unwrap();
718 let digest = "sha256:idempotent_test";
719
720 let source1 = tmp.path().join("v1");
721 create_test_layer(&source1, &[("v1.txt", "version 1")]);
722 let first = cache.put(digest, &source1).unwrap();
723
724 let source2 = tmp.path().join("v2");
725 create_test_layer(&source2, &[("v2.txt", "version 2")]);
726 let second = cache.put(digest, &source2).unwrap();
727
728 assert_eq!(first, second);
730 assert!(second.join("v1.txt").is_file());
731 assert!(!second.join("v2.txt").exists());
732 }
733
734 #[test]
735 fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
736 use std::sync::Arc;
737
738 let tmp = TempDir::new().unwrap();
739 let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
740 let digest = "sha256:concurrent_test";
741
742 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
745 let handles: Vec<_> = (0..12)
746 .map(|i| {
747 let cache = Arc::clone(&cache);
748 let src = tmp.path().join(format!("src{i}"));
749 create_test_layer(&src, files);
750 std::thread::spawn(move || cache.put(digest, &src).unwrap())
751 })
752 .collect();
753 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
754
755 for p in &paths {
758 assert_eq!(p, &paths[0]);
759 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
760 assert_eq!(
761 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
762 "beta"
763 );
764 }
765 assert!(cache.get(digest).unwrap().is_some());
766 }
767
768 #[cfg(windows)]
769 #[test]
770 fn test_write_meta_atomically_retries_windows_sharing_conflict() {
771 use std::os::windows::fs::OpenOptionsExt;
772 use std::time::{Duration, Instant};
773
774 let tmp = TempDir::new().unwrap();
775 let meta_path = tmp.path().join("entry.meta.json");
776 std::fs::write(&meta_path, r#"{"state":"old"}"#).unwrap();
777
778 let blocker = std::fs::OpenOptions::new()
779 .read(true)
780 .share_mode(0)
781 .open(&meta_path)
782 .unwrap();
783 let writer_path = meta_path.clone();
784 let writer =
785 std::thread::spawn(move || write_meta_atomically(&writer_path, r#"{"state":"new"}"#));
786
787 let deadline = Instant::now() + Duration::from_secs(1);
788 let staged_file_seen = loop {
789 let entry_count = std::fs::read_dir(tmp.path()).unwrap().count();
790 if entry_count >= 3 {
791 break true;
792 }
793 if writer.is_finished() || Instant::now() >= deadline {
794 break false;
795 }
796 std::thread::sleep(Duration::from_millis(5));
797 };
798 assert!(
799 staged_file_seen,
800 "metadata writer should stage a temporary file before retrying"
801 );
802 assert!(
803 !writer.is_finished(),
804 "metadata writer should wait for the Windows sharing conflict"
805 );
806
807 drop(blocker);
808 writer.join().unwrap().unwrap();
809 assert_eq!(
810 std::fs::read_to_string(meta_path).unwrap(),
811 r#"{"state":"new"}"#
812 );
813 }
814
815 #[test]
816 fn test_layer_cache_invalidate() {
817 let tmp = TempDir::new().unwrap();
818 let cache = LayerCache::new(tmp.path()).unwrap();
819 let digest = "sha256:to_invalidate";
820
821 let source = tmp.path().join("source");
822 create_test_layer(&source, &[("data.bin", "binary data")]);
823 cache.put(digest, &source).unwrap();
824
825 assert!(cache.get(digest).unwrap().is_some());
827
828 cache.invalidate(digest).unwrap();
830
831 assert!(cache.get(digest).unwrap().is_none());
833 }
834
835 #[test]
836 fn test_layer_cache_invalidate_nonexistent() {
837 let tmp = TempDir::new().unwrap();
838 let cache = LayerCache::new(tmp.path()).unwrap();
839
840 cache.invalidate("sha256:does_not_exist").unwrap();
842 }
843
844 #[test]
845 fn test_layer_cache_list_entries() {
846 let tmp = TempDir::new().unwrap();
847 let cache = LayerCache::new(tmp.path()).unwrap();
848
849 assert_eq!(cache.list_entries().unwrap().len(), 0);
851
852 let s1 = tmp.path().join("s1");
854 create_test_layer(&s1, &[("a.txt", "aaa")]);
855 cache.put("sha256:layer1", &s1).unwrap();
856
857 let s2 = tmp.path().join("s2");
858 create_test_layer(&s2, &[("b.txt", "bbb")]);
859 cache.put("sha256:layer2", &s2).unwrap();
860
861 let entries = cache.list_entries().unwrap();
862 assert_eq!(entries.len(), 2);
863
864 let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
865 assert!(digests.contains(&"sha256:layer1"));
866 assert!(digests.contains(&"sha256:layer2"));
867 }
868
869 #[test]
870 fn test_layer_cache_total_size() {
871 let tmp = TempDir::new().unwrap();
872 let cache = LayerCache::new(tmp.path()).unwrap();
873
874 assert_eq!(cache.total_size().unwrap(), 0);
875
876 let source = tmp.path().join("source");
877 create_test_layer(&source, &[("data.txt", "hello world")]);
878 cache.put("sha256:sized", &source).unwrap();
879
880 let total = cache.total_size().unwrap();
881 assert!(total > 0);
882 }
883
884 #[test]
885 fn test_layer_cache_prune_under_limit() {
886 let tmp = TempDir::new().unwrap();
887 let cache = LayerCache::new(tmp.path()).unwrap();
888
889 let source = tmp.path().join("source");
890 create_test_layer(&source, &[("small.txt", "tiny")]);
891 cache.put("sha256:small", &source).unwrap();
892
893 let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
895 assert_eq!(evicted, 0);
896 assert!(cache.get("sha256:small").unwrap().is_some());
897 }
898
899 #[test]
900 fn test_layer_cache_prune_evicts_oldest() {
901 let tmp = TempDir::new().unwrap();
902 let cache = LayerCache::new(tmp.path()).unwrap();
903
904 for i in 0..3 {
906 let source = tmp.path().join(format!("s{}", i));
907 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
909 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
910 std::thread::sleep(std::time::Duration::from_millis(10));
912 }
913
914 cache.get("sha256:layer2").unwrap();
916
917 let evicted = cache.prune(1).unwrap();
919 assert!(evicted >= 2);
920
921 }
924
925 #[test]
926 fn test_layer_cache_metadata_persists() {
927 let tmp = TempDir::new().unwrap();
928 let cache = LayerCache::new(tmp.path()).unwrap();
929 let digest = "sha256:meta_test";
930
931 let source = tmp.path().join("source");
932 create_test_layer(&source, &[("file.txt", "content")]);
933 cache.put(digest, &source).unwrap();
934
935 let meta_path = tmp.path().join("sha256_meta_test.meta.json");
937 assert!(meta_path.is_file());
938
939 let content = std::fs::read_to_string(&meta_path).unwrap();
940 let meta: LayerMeta = serde_json::from_str(&content).unwrap();
941
942 assert_eq!(meta.digest, digest);
943 assert!(meta.size_bytes > 0);
944 assert!(meta.cached_at > 0);
945 assert_eq!(meta.cached_at, meta.last_accessed);
946 }
947
948 #[test]
949 fn test_digest_to_dirname() {
950 assert_eq!(
951 LayerCache::digest_to_dirname("sha256:abc123"),
952 "sha256_abc123"
953 );
954 assert_eq!(
955 LayerCache::digest_to_dirname("plain_digest"),
956 "plain_digest"
957 );
958 }
959
960 #[test]
961 fn test_copy_dir_recursive() {
962 let tmp = TempDir::new().unwrap();
963 let src = tmp.path().join("src");
964 let dst = tmp.path().join("dst");
965
966 create_test_layer(
967 &src,
968 &[
969 ("a.txt", "aaa"),
970 ("sub/b.txt", "bbb"),
971 ("sub/deep/c.txt", "ccc"),
972 ],
973 );
974
975 copy_dir_recursive(&src, &dst).unwrap();
976
977 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
978 assert_eq!(
979 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
980 "bbb"
981 );
982 assert_eq!(
983 std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
984 "ccc"
985 );
986 }
987
988 #[test]
989 fn test_copy_dir_recursive_copies_contents_into_existing_destination() {
990 let tmp = TempDir::new().unwrap();
991 let src = tmp.path().join("rootfs");
992 let dst = tmp.path().join("existing");
993
994 create_test_layer(&src, &[("bin/sh", "shell"), ("etc/config", "value")]);
995 std::fs::create_dir_all(&dst).unwrap();
996 std::fs::write(dst.join("marker"), "keep").unwrap();
997
998 copy_dir_recursive(&src, &dst).unwrap();
999
1000 assert_eq!(
1001 std::fs::read_to_string(dst.join("bin/sh")).unwrap(),
1002 "shell"
1003 );
1004 assert_eq!(
1005 std::fs::read_to_string(dst.join("etc/config")).unwrap(),
1006 "value"
1007 );
1008 assert_eq!(std::fs::read_to_string(dst.join("marker")).unwrap(), "keep");
1009 assert!(!dst.join("rootfs").exists());
1010 }
1011
1012 #[cfg(unix)]
1013 #[test]
1014 fn test_copy_dir_recursive_preserves_symlinks() {
1015 let tmp = TempDir::new().unwrap();
1016 let src = tmp.path().join("src");
1017 let dst = tmp.path().join("dst");
1018 std::fs::create_dir_all(&src).unwrap();
1019 std::fs::write(src.join("target.txt"), "target").unwrap();
1020 std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
1021
1022 copy_dir_recursive(&src, &dst).unwrap();
1023
1024 let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
1025 assert!(link_meta.file_type().is_symlink());
1026 assert_eq!(
1027 std::fs::read_link(dst.join("link.txt")).unwrap(),
1028 std::path::PathBuf::from("target.txt")
1029 );
1030 }
1031
1032 #[cfg(target_os = "windows")]
1033 #[test]
1034 fn test_copy_dir_recursive_preserves_windows_symlink() {
1035 let tmp = TempDir::new().unwrap();
1036 let src = tmp.path().join("src");
1037 let dst = tmp.path().join("dst");
1038 std::fs::create_dir_all(&src).unwrap();
1039 std::fs::write(src.join("target.txt"), "target").unwrap();
1040 if let Err(error) = std::os::windows::fs::symlink_file("target.txt", src.join("link.txt")) {
1041 const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
1045 if error.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD) {
1046 return;
1047 }
1048 panic!("failed to create Windows test symlink: {error}");
1049 }
1050
1051 copy_dir_recursive(&src, &dst).unwrap();
1052
1053 let copied_link = dst.join("link.txt");
1054 assert!(std::fs::symlink_metadata(&copied_link)
1055 .unwrap()
1056 .file_type()
1057 .is_symlink());
1058 assert_eq!(
1059 std::fs::read_link(&copied_link).unwrap(),
1060 Path::new("target.txt")
1061 );
1062 assert_eq!(std::fs::read_to_string(copied_link).unwrap(), "target");
1063 }
1064
1065 #[test]
1066 fn test_dir_size() {
1067 let tmp = TempDir::new().unwrap();
1068 let dir = tmp.path().join("sized");
1069 create_test_layer(
1070 &dir,
1071 &[
1072 ("a.txt", "hello"), ("sub/b.txt", "world"), ],
1075 );
1076
1077 let size = dir_size(&dir).unwrap();
1078 assert_eq!(size, 10);
1079 }
1080
1081 #[cfg(unix)]
1082 #[test]
1083 fn test_dir_size_does_not_follow_external_directory_symlink() {
1084 let tmp = TempDir::new().unwrap();
1085 let dir = tmp.path().join("sized");
1086 let outside = tmp.path().join("outside");
1087 std::fs::create_dir_all(&dir).unwrap();
1088 std::fs::create_dir_all(&outside).unwrap();
1089 std::fs::write(dir.join("local"), b"local").unwrap();
1090 std::fs::write(outside.join("host-data"), vec![0_u8; 4096]).unwrap();
1091 let link = dir.join("external");
1092 std::os::unix::fs::symlink(&outside, &link).unwrap();
1093
1094 let size = dir_size(&dir).unwrap();
1095
1096 assert_eq!(
1097 size,
1098 5 + std::fs::symlink_metadata(link).unwrap().len(),
1099 "cache sizing must count the symlink itself without entering its target"
1100 );
1101 }
1102
1103 #[cfg(unix)]
1104 #[test]
1105 fn test_dir_size_does_not_follow_symlink_cycle() {
1106 let tmp = TempDir::new().unwrap();
1107 let dir = tmp.path().join("sized");
1108 std::fs::create_dir_all(&dir).unwrap();
1109 let link = dir.join("loop");
1110 std::os::unix::fs::symlink(".", &link).unwrap();
1111
1112 let size = dir_size(&dir).unwrap();
1113
1114 assert_eq!(size, std::fs::symlink_metadata(link).unwrap().len());
1115 }
1116
1117 #[test]
1118 fn test_dir_size_empty_directory() {
1119 let tmp = TempDir::new().unwrap();
1120 let dir = tmp.path().join("empty");
1121 std::fs::create_dir_all(&dir).unwrap();
1122
1123 let size = dir_size(&dir).unwrap();
1124 assert_eq!(size, 0);
1125 }
1126
1127 #[test]
1128 fn test_dir_size_nonexistent_returns_zero() {
1129 let tmp = TempDir::new().unwrap();
1130 let dir = tmp.path().join("nonexistent");
1131
1132 let size = dir_size(&dir).unwrap();
1134 assert_eq!(size, 0);
1135 }
1136
1137 #[test]
1138 fn test_copy_dir_recursive_empty_directory() {
1139 let tmp = TempDir::new().unwrap();
1140 let src = tmp.path().join("empty_src");
1141 let dst = tmp.path().join("empty_dst");
1142 std::fs::create_dir_all(&src).unwrap();
1143
1144 copy_dir_recursive(&src, &dst).unwrap();
1145 assert!(dst.is_dir());
1146 }
1147
1148 #[test]
1149 fn test_copy_dir_recursive_source_not_exists() {
1150 let tmp = TempDir::new().unwrap();
1151 let src = tmp.path().join("nonexistent");
1152 let dst = tmp.path().join("dst");
1153
1154 let result = copy_dir_recursive(&src, &dst);
1155 assert!(result.is_err());
1156 }
1157
1158 #[test]
1159 fn test_layer_cache_get_updates_last_accessed() {
1160 let tmp = TempDir::new().unwrap();
1161 let cache = LayerCache::new(tmp.path()).unwrap();
1162 let digest = "sha256:access_test";
1163
1164 let source = tmp.path().join("source");
1165 create_test_layer(&source, &[("f.txt", "data")]);
1166 cache.put(digest, &source).unwrap();
1167
1168 let meta_path = tmp.path().join("sha256_access_test.meta.json");
1170 let content = std::fs::read_to_string(&meta_path).unwrap();
1171 let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
1172
1173 std::thread::sleep(std::time::Duration::from_millis(10));
1175
1176 cache.get(digest).unwrap();
1178
1179 let content = std::fs::read_to_string(&meta_path).unwrap();
1181 let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
1182
1183 assert!(meta_after.last_accessed >= meta_before.last_accessed);
1184 assert_eq!(meta_after.cached_at, meta_before.cached_at);
1186 }
1187
1188 #[test]
1189 fn test_layer_cache_get_corrupted_metadata() {
1190 let tmp = TempDir::new().unwrap();
1191 let cache = LayerCache::new(tmp.path()).unwrap();
1192 let digest = "sha256:corrupted";
1193 let safe_name = LayerCache::digest_to_dirname(digest);
1194
1195 let layer_dir = tmp.path().join(&safe_name);
1197 std::fs::create_dir_all(&layer_dir).unwrap();
1198
1199 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1201 std::fs::write(&meta_path, "not valid json!!!").unwrap();
1202
1203 let result = cache.get(digest).unwrap();
1206 assert!(result.is_some());
1207 }
1208
1209 #[test]
1210 fn test_layer_cache_get_directory_without_metadata() {
1211 let tmp = TempDir::new().unwrap();
1212 let cache = LayerCache::new(tmp.path()).unwrap();
1213 let digest = "sha256:no_meta";
1214 let safe_name = LayerCache::digest_to_dirname(digest);
1215
1216 let layer_dir = tmp.path().join(&safe_name);
1218 std::fs::create_dir_all(&layer_dir).unwrap();
1219
1220 let result = cache.get(digest).unwrap();
1222 assert!(result.is_none());
1223 }
1224
1225 #[test]
1226 fn test_layer_cache_get_metadata_without_directory() {
1227 let tmp = TempDir::new().unwrap();
1228 let cache = LayerCache::new(tmp.path()).unwrap();
1229 let digest = "sha256:no_dir";
1230 let safe_name = LayerCache::digest_to_dirname(digest);
1231
1232 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1234 let meta = LayerMeta {
1235 digest: digest.to_string(),
1236 size_bytes: 0,
1237 cached_at: 0,
1238 last_accessed: 0,
1239 };
1240 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1241
1242 let result = cache.get(digest).unwrap();
1244 assert!(result.is_none());
1245 }
1246
1247 #[test]
1248 fn test_layer_cache_put_source_not_exists() {
1249 let tmp = TempDir::new().unwrap();
1250 let cache = LayerCache::new(tmp.path()).unwrap();
1251
1252 let nonexistent = tmp.path().join("does_not_exist");
1253 let result = cache.put("sha256:bad_source", &nonexistent);
1254 assert!(result.is_err());
1255 }
1256
1257 #[test]
1258 fn test_layer_cache_prune_zero_limit() {
1259 let tmp = TempDir::new().unwrap();
1260 let cache = LayerCache::new(tmp.path()).unwrap();
1261
1262 let source = tmp.path().join("source");
1263 create_test_layer(&source, &[("f.txt", "data")]);
1264 cache.put("sha256:entry1", &source).unwrap();
1265 cache.put("sha256:entry2", &source).unwrap();
1266
1267 let evicted = cache.prune(0).unwrap();
1269 assert_eq!(evicted, 2);
1270 assert_eq!(cache.list_entries().unwrap().len(), 0);
1271 }
1272
1273 #[test]
1274 fn test_layer_cache_list_entries_ignores_non_meta_files() {
1275 let tmp = TempDir::new().unwrap();
1276 let cache = LayerCache::new(tmp.path()).unwrap();
1277
1278 let source = tmp.path().join("source");
1280 create_test_layer(&source, &[("f.txt", "data")]);
1281 cache.put("sha256:valid", &source).unwrap();
1282
1283 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1285 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1286
1287 let entries = cache.list_entries().unwrap();
1289 assert_eq!(entries.len(), 1);
1290 assert_eq!(entries[0].digest, "sha256:valid");
1291 }
1292
1293 #[test]
1294 fn test_layer_cache_list_entries_skips_invalid_json() {
1295 let tmp = TempDir::new().unwrap();
1296 let cache = LayerCache::new(tmp.path()).unwrap();
1297
1298 let source = tmp.path().join("source");
1300 create_test_layer(&source, &[("f.txt", "data")]);
1301 cache.put("sha256:valid", &source).unwrap();
1302
1303 std::fs::write(
1305 tmp.path().join("sha256_corrupted.meta.json"),
1306 "not json at all",
1307 )
1308 .unwrap();
1309
1310 let entries = cache.list_entries().unwrap();
1312 assert_eq!(entries.len(), 1);
1313 assert_eq!(entries[0].digest, "sha256:valid");
1314 }
1315
1316 #[test]
1317 fn test_layer_cache_put_preserves_file_content() {
1318 let tmp = TempDir::new().unwrap();
1319 let cache = LayerCache::new(tmp.path()).unwrap();
1320
1321 let source = tmp.path().join("source");
1322 create_test_layer(
1323 &source,
1324 &[
1325 ("binary.bin", "\x00\x01\x02\x03"),
1326 ("text.txt", "hello world\n"),
1327 ],
1328 );
1329
1330 let cached = cache.put("sha256:content_check", &source).unwrap();
1331
1332 assert_eq!(
1333 std::fs::read(cached.join("binary.bin")).unwrap(),
1334 b"\x00\x01\x02\x03"
1335 );
1336 assert_eq!(
1337 std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1338 "hello world\n"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_layer_cache_multiple_colons_in_digest() {
1344 let tmp = TempDir::new().unwrap();
1345 let cache = LayerCache::new(tmp.path()).unwrap();
1346
1347 let digest = "sha256:abc:def:ghi";
1348 let source = tmp.path().join("source");
1349 create_test_layer(&source, &[("f.txt", "data")]);
1350
1351 cache.put(digest, &source).unwrap();
1352 let result = cache.get(digest).unwrap();
1353 assert!(result.is_some());
1354
1355 cache.invalidate(digest).unwrap();
1356 assert!(cache.get(digest).unwrap().is_none());
1357 }
1358
1359 #[test]
1360 fn test_copy_file_cow_preserves_content_and_mode() {
1361 let tmp = TempDir::new().unwrap();
1364 let src = tmp.path().join("src.bin");
1365 let dst = tmp.path().join("dst.bin");
1366 std::fs::write(&src, b"hello copy-on-write").unwrap();
1367 #[cfg(unix)]
1368 {
1369 use std::os::unix::fs::PermissionsExt;
1370 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1371 }
1372
1373 copy_file_cow(&src, &dst).unwrap();
1374
1375 assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1376 #[cfg(unix)]
1377 {
1378 use std::os::unix::fs::PermissionsExt;
1379 let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1380 assert_eq!(mode, 0o755, "executable bit must survive the copy");
1381 }
1382 }
1383
1384 #[cfg(unix)]
1385 #[test]
1386 fn test_copy_dir_recursive_preserves_directory_modes() {
1387 use std::os::unix::fs::PermissionsExt;
1388
1389 let tmp = TempDir::new().unwrap();
1390 let src = tmp.path().join("src");
1391 let nested = src.join("nested");
1392 let dst = tmp.path().join("dst");
1393 std::fs::create_dir_all(&nested).unwrap();
1394 std::fs::write(nested.join("file"), b"content").unwrap();
1395 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1396 std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o710)).unwrap();
1397
1398 copy_dir_recursive(&src, &dst).unwrap();
1399
1400 let root_mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1401 let nested_mode = std::fs::metadata(dst.join("nested"))
1402 .unwrap()
1403 .permissions()
1404 .mode()
1405 & 0o777;
1406 assert_eq!(root_mode, 0o755);
1407 assert_eq!(nested_mode, 0o710);
1408 }
1409
1410 #[test]
1411 fn test_copy_file_cow_overwrites_existing_dst() {
1412 let tmp = TempDir::new().unwrap();
1414 let src = tmp.path().join("src");
1415 let dst = tmp.path().join("dst");
1416 std::fs::write(&src, b"new").unwrap();
1417 std::fs::write(&dst, b"old-and-longer").unwrap();
1418 copy_file_cow(&src, &dst).unwrap();
1419 assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1420 }
1421}