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) = std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?) {
67 tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update layer cache metadata");
68 }
69 }
70 }
71
72 Ok(Some(layer_dir))
73 }
74
75 pub fn put(&self, digest: &str, source_dir: &Path) -> Result<PathBuf> {
80 let safe_name = Self::digest_to_dirname(digest);
81 let layer_dir = self.cache_dir.join(&safe_name);
82 let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
83
84 if layer_dir.is_dir() && meta_path.is_file() {
87 return Ok(layer_dir);
88 }
89
90 publish_dir_atomically(source_dir, &layer_dir, &self.cache_dir)?;
94
95 let size_bytes = dir_size(&layer_dir).unwrap_or(0);
97
98 let now = chrono::Utc::now().timestamp();
100 let meta = LayerMeta {
101 digest: digest.to_string(),
102 size_bytes,
103 cached_at: now,
104 last_accessed: now,
105 };
106 write_meta_atomically(&meta_path, &serde_json::to_string_pretty(&meta)?)?;
107
108 tracing::debug!(
109 digest = %digest,
110 size_bytes,
111 path = %layer_dir.display(),
112 "Cached OCI layer"
113 );
114
115 Ok(layer_dir)
116 }
117
118 pub fn invalidate(&self, digest: &str) -> Result<()> {
120 let safe_name = Self::digest_to_dirname(digest);
121 let layer_dir = self.cache_dir.join(&safe_name);
122 let meta_path = self.cache_dir.join(format!("{}.meta.json", safe_name));
123
124 if layer_dir.exists() {
125 std::fs::remove_dir_all(&layer_dir).map_err(|e| {
126 BoxError::CacheError(format!(
127 "Failed to remove cached layer {}: {}",
128 layer_dir.display(),
129 e
130 ))
131 })?;
132 }
133 if meta_path.exists() {
134 std::fs::remove_file(&meta_path).map_err(|e| {
135 BoxError::CacheError(format!(
136 "Failed to remove layer metadata {}: {}",
137 meta_path.display(),
138 e
139 ))
140 })?;
141 }
142
143 Ok(())
144 }
145
146 pub fn prune(&self, max_bytes: u64) -> Result<usize> {
151 let mut entries = self.list_entries()?;
152
153 let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
155 if total_size <= max_bytes {
156 return Ok(0);
157 }
158
159 entries.sort_by_key(|e| e.last_accessed);
161
162 let mut current_size = total_size;
163 let mut evicted = 0;
164
165 for entry in &entries {
166 if current_size <= max_bytes {
167 break;
168 }
169 self.invalidate(&entry.digest)?;
170 current_size = current_size.saturating_sub(entry.size_bytes);
171 evicted += 1;
172
173 tracing::debug!(
174 digest = %entry.digest,
175 size_bytes = entry.size_bytes,
176 "Evicted cached layer"
177 );
178 }
179
180 Ok(evicted)
181 }
182
183 pub fn list_entries(&self) -> Result<Vec<LayerMeta>> {
185 let mut entries = Vec::new();
186
187 let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
188 BoxError::CacheError(format!(
189 "Failed to read cache directory {}: {}",
190 self.cache_dir.display(),
191 e
192 ))
193 })?;
194
195 for entry in read_dir {
196 let entry = entry.map_err(|e| {
197 BoxError::CacheError(format!("Failed to read directory entry: {}", e))
198 })?;
199 let path = entry.path();
200
201 if path.extension().and_then(|e| e.to_str()) == Some("json") {
203 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
204 if name.ends_with(".meta.json") {
205 if let Ok(content) = std::fs::read_to_string(&path) {
206 if let Ok(meta) = serde_json::from_str::<LayerMeta>(&content) {
207 entries.push(meta);
208 }
209 }
210 }
211 }
212 }
213 }
214
215 Ok(entries)
216 }
217
218 pub fn total_size(&self) -> Result<u64> {
220 Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
221 }
222
223 fn digest_to_dirname(digest: &str) -> String {
228 digest.replace(':', "_")
229 }
230}
231
232#[cfg(unix)]
240fn preserve_owner(meta: &std::fs::Metadata, dst: &Path) {
241 use std::os::unix::ffi::OsStrExt;
242 use std::os::unix::fs::MetadataExt;
243 if unsafe { libc::geteuid() } != 0 {
244 return;
245 }
246 if let Ok(c_path) = std::ffi::CString::new(dst.as_os_str().as_bytes()) {
247 unsafe {
249 libc::lchown(c_path.as_ptr(), meta.uid(), meta.gid());
250 }
251 }
252}
253
254#[cfg(not(unix))]
255fn preserve_owner(_meta: &std::fs::Metadata, _dst: &Path) {}
256
257pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
258 #[cfg(target_os = "macos")]
259 {
260 let dst_preexisted = dst.exists();
261 if copy_dir_recursive_cow(src, dst).unwrap_or(false) {
262 return Ok(());
263 }
264 if !dst_preexisted && dst.exists() {
265 let _ = std::fs::remove_dir_all(dst);
266 }
267 }
268
269 std::fs::create_dir_all(dst).map_err(|e| {
270 BoxError::CacheError(format!(
271 "Failed to create directory {}: {}",
272 dst.display(),
273 e
274 ))
275 })?;
276 if let Ok(src_meta) = std::fs::symlink_metadata(src) {
278 preserve_owner(&src_meta, dst);
279 }
280
281 for entry in std::fs::read_dir(src).map_err(|e| {
282 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
283 })? {
284 let entry = entry
285 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
286 let src_path = entry.path();
287 let dst_path = dst.join(entry.file_name());
288
289 let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
291 BoxError::CacheError(format!(
292 "Failed to read metadata for {}: {}",
293 src_path.display(),
294 e
295 ))
296 })?;
297
298 if meta.is_symlink() {
299 #[cfg(unix)]
300 {
301 let target = std::fs::read_link(&src_path).map_err(|e| {
302 BoxError::CacheError(format!(
303 "Failed to read symlink {}: {}",
304 src_path.display(),
305 e
306 ))
307 })?;
308 std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
309 BoxError::CacheError(format!(
310 "Failed to create symlink {} -> {}: {}",
311 dst_path.display(),
312 target.display(),
313 e
314 ))
315 })?;
316 preserve_owner(&meta, &dst_path);
317 }
318 #[cfg(not(unix))]
319 {
320 return Err(BoxError::CacheError(format!(
321 "Symlink copy is not supported on this platform: {}",
322 src_path.display()
323 )));
324 }
325 } else if meta.is_dir() {
326 copy_dir_recursive(&src_path, &dst_path)?;
327 } else {
328 copy_file_cow(&src_path, &dst_path).map_err(|e| {
329 BoxError::CacheError(format!(
330 "Failed to copy {} to {}: {}",
331 src_path.display(),
332 dst_path.display(),
333 e
334 ))
335 })?;
336 preserve_owner(&meta, &dst_path);
337 }
338 }
339
340 Ok(())
341}
342
343#[cfg(target_os = "macos")]
344fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
345 use std::os::unix::ffi::OsStrExt;
346
347 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
348 std::io::Error::new(
349 std::io::ErrorKind::InvalidInput,
350 "source path contains a NUL byte",
351 )
352 })?;
353 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
354 std::io::Error::new(
355 std::io::ErrorKind::InvalidInput,
356 "destination path contains a NUL byte",
357 )
358 })?;
359 let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
360 let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
364 Ok(rc == 0)
365}
366
367fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
374 #[cfg(target_os = "linux")]
375 {
376 use std::os::unix::io::AsRawFd;
377 const FICLONE: libc::c_ulong = 0x4004_9409;
379 let reflinked = (|| -> std::io::Result<bool> {
380 let s = std::fs::File::open(src)?;
381 let d = std::fs::OpenOptions::new()
382 .write(true)
383 .create(true)
384 .truncate(true)
385 .open(dst)?;
386 let rc = unsafe { libc::ioctl(d.as_raw_fd(), FICLONE, s.as_raw_fd()) };
390 if rc != 0 {
391 return Ok(false);
392 }
393 if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
395 let _ = d.set_permissions(perm);
396 }
397 Ok(true)
398 })()
399 .unwrap_or(false);
400 if reflinked {
401 return Ok(());
402 }
403 }
404
405 #[cfg(target_os = "macos")]
406 {
407 use std::os::unix::ffi::OsStrExt;
408
409 let cloned = (|| -> std::io::Result<bool> {
410 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
411 std::io::Error::new(
412 std::io::ErrorKind::InvalidInput,
413 "source path contains a NUL byte",
414 )
415 })?;
416 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
417 std::io::Error::new(
418 std::io::ErrorKind::InvalidInput,
419 "destination path contains a NUL byte",
420 )
421 })?;
422
423 let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
427 if rc != 0 {
428 return Ok(false);
429 }
430 if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
431 let _ = std::fs::set_permissions(dst, perm);
432 }
433 Ok(true)
434 })()
435 .unwrap_or(false);
436 if cloned {
437 return Ok(());
438 }
439 }
440
441 std::fs::copy(src, dst).map(|_| ())
442}
443
444pub(crate) fn publish_dir_atomically(
458 source_dir: &Path,
459 dest_dir: &Path,
460 staging_parent: &Path,
461) -> Result<bool> {
462 if dest_dir.exists() {
463 return Ok(false);
464 }
465 let staging = tempfile::Builder::new()
466 .prefix(".staging-")
467 .tempdir_in(staging_parent)
468 .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
469 let staged = staging.path().join("d");
472 copy_dir_recursive(source_dir, &staged)?;
473
474 match std::fs::rename(&staged, dest_dir) {
475 Ok(()) => Ok(true),
476 Err(_) if dest_dir.exists() => Ok(false),
479 Err(e) => Err(BoxError::CacheError(format!(
480 "Failed to publish cache entry {}: {e}",
481 dest_dir.display()
482 ))),
483 }
484}
485
486pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
489 let parent = meta_path.parent().ok_or_else(|| {
490 BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
491 })?;
492 let mut tmp = tempfile::NamedTempFile::new_in(parent)
493 .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
494 use std::io::Write as _;
495 tmp.write_all(json.as_bytes())
496 .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
497 tmp.persist(meta_path)
498 .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
499 Ok(())
500}
501
502pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
504 let mut total = 0;
505 if path.is_dir() {
506 for entry in std::fs::read_dir(path)? {
507 let entry = entry?;
508 let path = entry.path();
509 if path.is_dir() {
510 total += dir_size(&path)?;
511 } else {
512 total += entry.metadata()?.len();
513 }
514 }
515 }
516 Ok(total)
517}
518
519impl CacheBackend for LayerCache {
520 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
521 self.get(key)
522 }
523
524 fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
525 self.put(key, source_dir)
526 }
527
528 fn invalidate(&self, key: &str) -> Result<()> {
529 self.invalidate(key)
530 }
531
532 fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
533 self.prune(max_bytes)
534 }
535
536 fn list(&self) -> Result<Vec<CacheEntry>> {
537 self.list_entries().map(|entries| {
538 entries
539 .into_iter()
540 .map(|m| CacheEntry {
541 key: m.digest,
542 description: String::new(),
543 size_bytes: m.size_bytes,
544 cached_at: m.cached_at,
545 last_accessed: m.last_accessed,
546 })
547 .collect()
548 })
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use tempfile::TempDir;
556
557 fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
558 std::fs::create_dir_all(dir).unwrap();
559 for (name, content) in files {
560 let file_path = dir.join(name);
561 if let Some(parent) = file_path.parent() {
562 std::fs::create_dir_all(parent).unwrap();
563 }
564 std::fs::write(&file_path, content).unwrap();
565 }
566 }
567
568 #[test]
569 fn test_layer_cache_new_creates_directory() {
570 let tmp = TempDir::new().unwrap();
571 let cache_dir = tmp.path().join("layers");
572
573 assert!(!cache_dir.exists());
574 let _cache = LayerCache::new(&cache_dir).unwrap();
575 assert!(cache_dir.is_dir());
576 }
577
578 #[test]
579 fn test_layer_cache_get_miss() {
580 let tmp = TempDir::new().unwrap();
581 let cache = LayerCache::new(tmp.path()).unwrap();
582
583 let result = cache.get("sha256:nonexistent").unwrap();
584 assert!(result.is_none());
585 }
586
587 #[test]
588 fn test_layer_cache_put_and_get() {
589 let tmp = TempDir::new().unwrap();
590 let cache = LayerCache::new(tmp.path()).unwrap();
591
592 let source = tmp.path().join("source_layer");
594 create_test_layer(
595 &source,
596 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
597 );
598
599 let digest = "sha256:abc123def456";
601 let cached_path = cache.put(digest, &source).unwrap();
602
603 assert!(cached_path.is_dir());
604 assert!(cached_path.join("file.txt").is_file());
605 assert!(cached_path.join("sub/nested.txt").is_file());
606
607 let result = cache.get(digest).unwrap();
609 assert!(result.is_some());
610 assert_eq!(result.unwrap(), cached_path);
611 }
612
613 #[test]
614 fn test_layer_cache_put_same_digest_is_idempotent() {
615 let tmp = TempDir::new().unwrap();
621 let cache = LayerCache::new(tmp.path()).unwrap();
622 let digest = "sha256:idempotent_test";
623
624 let source1 = tmp.path().join("v1");
625 create_test_layer(&source1, &[("v1.txt", "version 1")]);
626 let first = cache.put(digest, &source1).unwrap();
627
628 let source2 = tmp.path().join("v2");
629 create_test_layer(&source2, &[("v2.txt", "version 2")]);
630 let second = cache.put(digest, &source2).unwrap();
631
632 assert_eq!(first, second);
634 assert!(second.join("v1.txt").is_file());
635 assert!(!second.join("v2.txt").exists());
636 }
637
638 #[test]
639 fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
640 use std::sync::Arc;
641
642 let tmp = TempDir::new().unwrap();
643 let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
644 let digest = "sha256:concurrent_test";
645
646 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
649 let handles: Vec<_> = (0..12)
650 .map(|i| {
651 let cache = Arc::clone(&cache);
652 let src = tmp.path().join(format!("src{i}"));
653 create_test_layer(&src, files);
654 std::thread::spawn(move || cache.put(digest, &src).unwrap())
655 })
656 .collect();
657 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
658
659 for p in &paths {
662 assert_eq!(p, &paths[0]);
663 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
664 assert_eq!(
665 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
666 "beta"
667 );
668 }
669 assert!(cache.get(digest).unwrap().is_some());
670 }
671
672 #[test]
673 fn test_layer_cache_invalidate() {
674 let tmp = TempDir::new().unwrap();
675 let cache = LayerCache::new(tmp.path()).unwrap();
676 let digest = "sha256:to_invalidate";
677
678 let source = tmp.path().join("source");
679 create_test_layer(&source, &[("data.bin", "binary data")]);
680 cache.put(digest, &source).unwrap();
681
682 assert!(cache.get(digest).unwrap().is_some());
684
685 cache.invalidate(digest).unwrap();
687
688 assert!(cache.get(digest).unwrap().is_none());
690 }
691
692 #[test]
693 fn test_layer_cache_invalidate_nonexistent() {
694 let tmp = TempDir::new().unwrap();
695 let cache = LayerCache::new(tmp.path()).unwrap();
696
697 cache.invalidate("sha256:does_not_exist").unwrap();
699 }
700
701 #[test]
702 fn test_layer_cache_list_entries() {
703 let tmp = TempDir::new().unwrap();
704 let cache = LayerCache::new(tmp.path()).unwrap();
705
706 assert_eq!(cache.list_entries().unwrap().len(), 0);
708
709 let s1 = tmp.path().join("s1");
711 create_test_layer(&s1, &[("a.txt", "aaa")]);
712 cache.put("sha256:layer1", &s1).unwrap();
713
714 let s2 = tmp.path().join("s2");
715 create_test_layer(&s2, &[("b.txt", "bbb")]);
716 cache.put("sha256:layer2", &s2).unwrap();
717
718 let entries = cache.list_entries().unwrap();
719 assert_eq!(entries.len(), 2);
720
721 let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
722 assert!(digests.contains(&"sha256:layer1"));
723 assert!(digests.contains(&"sha256:layer2"));
724 }
725
726 #[test]
727 fn test_layer_cache_total_size() {
728 let tmp = TempDir::new().unwrap();
729 let cache = LayerCache::new(tmp.path()).unwrap();
730
731 assert_eq!(cache.total_size().unwrap(), 0);
732
733 let source = tmp.path().join("source");
734 create_test_layer(&source, &[("data.txt", "hello world")]);
735 cache.put("sha256:sized", &source).unwrap();
736
737 let total = cache.total_size().unwrap();
738 assert!(total > 0);
739 }
740
741 #[test]
742 fn test_layer_cache_prune_under_limit() {
743 let tmp = TempDir::new().unwrap();
744 let cache = LayerCache::new(tmp.path()).unwrap();
745
746 let source = tmp.path().join("source");
747 create_test_layer(&source, &[("small.txt", "tiny")]);
748 cache.put("sha256:small", &source).unwrap();
749
750 let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
752 assert_eq!(evicted, 0);
753 assert!(cache.get("sha256:small").unwrap().is_some());
754 }
755
756 #[test]
757 fn test_layer_cache_prune_evicts_oldest() {
758 let tmp = TempDir::new().unwrap();
759 let cache = LayerCache::new(tmp.path()).unwrap();
760
761 for i in 0..3 {
763 let source = tmp.path().join(format!("s{}", i));
764 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
766 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
767 std::thread::sleep(std::time::Duration::from_millis(10));
769 }
770
771 cache.get("sha256:layer2").unwrap();
773
774 let evicted = cache.prune(1).unwrap();
776 assert!(evicted >= 2);
777
778 }
781
782 #[test]
783 fn test_layer_cache_metadata_persists() {
784 let tmp = TempDir::new().unwrap();
785 let cache = LayerCache::new(tmp.path()).unwrap();
786 let digest = "sha256:meta_test";
787
788 let source = tmp.path().join("source");
789 create_test_layer(&source, &[("file.txt", "content")]);
790 cache.put(digest, &source).unwrap();
791
792 let meta_path = tmp.path().join("sha256_meta_test.meta.json");
794 assert!(meta_path.is_file());
795
796 let content = std::fs::read_to_string(&meta_path).unwrap();
797 let meta: LayerMeta = serde_json::from_str(&content).unwrap();
798
799 assert_eq!(meta.digest, digest);
800 assert!(meta.size_bytes > 0);
801 assert!(meta.cached_at > 0);
802 assert_eq!(meta.cached_at, meta.last_accessed);
803 }
804
805 #[test]
806 fn test_digest_to_dirname() {
807 assert_eq!(
808 LayerCache::digest_to_dirname("sha256:abc123"),
809 "sha256_abc123"
810 );
811 assert_eq!(
812 LayerCache::digest_to_dirname("plain_digest"),
813 "plain_digest"
814 );
815 }
816
817 #[test]
818 fn test_copy_dir_recursive() {
819 let tmp = TempDir::new().unwrap();
820 let src = tmp.path().join("src");
821 let dst = tmp.path().join("dst");
822
823 create_test_layer(
824 &src,
825 &[
826 ("a.txt", "aaa"),
827 ("sub/b.txt", "bbb"),
828 ("sub/deep/c.txt", "ccc"),
829 ],
830 );
831
832 copy_dir_recursive(&src, &dst).unwrap();
833
834 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
835 assert_eq!(
836 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
837 "bbb"
838 );
839 assert_eq!(
840 std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
841 "ccc"
842 );
843 }
844
845 #[cfg(unix)]
846 #[test]
847 fn test_copy_dir_recursive_preserves_symlinks() {
848 let tmp = TempDir::new().unwrap();
849 let src = tmp.path().join("src");
850 let dst = tmp.path().join("dst");
851 std::fs::create_dir_all(&src).unwrap();
852 std::fs::write(src.join("target.txt"), "target").unwrap();
853 std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
854
855 copy_dir_recursive(&src, &dst).unwrap();
856
857 let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
858 assert!(link_meta.file_type().is_symlink());
859 assert_eq!(
860 std::fs::read_link(dst.join("link.txt")).unwrap(),
861 std::path::PathBuf::from("target.txt")
862 );
863 }
864
865 #[test]
866 fn test_dir_size() {
867 let tmp = TempDir::new().unwrap();
868 let dir = tmp.path().join("sized");
869 create_test_layer(
870 &dir,
871 &[
872 ("a.txt", "hello"), ("sub/b.txt", "world"), ],
875 );
876
877 let size = dir_size(&dir).unwrap();
878 assert_eq!(size, 10);
879 }
880
881 #[test]
882 fn test_dir_size_empty_directory() {
883 let tmp = TempDir::new().unwrap();
884 let dir = tmp.path().join("empty");
885 std::fs::create_dir_all(&dir).unwrap();
886
887 let size = dir_size(&dir).unwrap();
888 assert_eq!(size, 0);
889 }
890
891 #[test]
892 fn test_dir_size_nonexistent_returns_zero() {
893 let tmp = TempDir::new().unwrap();
894 let dir = tmp.path().join("nonexistent");
895
896 let size = dir_size(&dir).unwrap();
898 assert_eq!(size, 0);
899 }
900
901 #[test]
902 fn test_copy_dir_recursive_empty_directory() {
903 let tmp = TempDir::new().unwrap();
904 let src = tmp.path().join("empty_src");
905 let dst = tmp.path().join("empty_dst");
906 std::fs::create_dir_all(&src).unwrap();
907
908 copy_dir_recursive(&src, &dst).unwrap();
909 assert!(dst.is_dir());
910 }
911
912 #[test]
913 fn test_copy_dir_recursive_source_not_exists() {
914 let tmp = TempDir::new().unwrap();
915 let src = tmp.path().join("nonexistent");
916 let dst = tmp.path().join("dst");
917
918 let result = copy_dir_recursive(&src, &dst);
919 assert!(result.is_err());
920 }
921
922 #[test]
923 fn test_layer_cache_get_updates_last_accessed() {
924 let tmp = TempDir::new().unwrap();
925 let cache = LayerCache::new(tmp.path()).unwrap();
926 let digest = "sha256:access_test";
927
928 let source = tmp.path().join("source");
929 create_test_layer(&source, &[("f.txt", "data")]);
930 cache.put(digest, &source).unwrap();
931
932 let meta_path = tmp.path().join("sha256_access_test.meta.json");
934 let content = std::fs::read_to_string(&meta_path).unwrap();
935 let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
936
937 std::thread::sleep(std::time::Duration::from_millis(10));
939
940 cache.get(digest).unwrap();
942
943 let content = std::fs::read_to_string(&meta_path).unwrap();
945 let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
946
947 assert!(meta_after.last_accessed >= meta_before.last_accessed);
948 assert_eq!(meta_after.cached_at, meta_before.cached_at);
950 }
951
952 #[test]
953 fn test_layer_cache_get_corrupted_metadata() {
954 let tmp = TempDir::new().unwrap();
955 let cache = LayerCache::new(tmp.path()).unwrap();
956 let digest = "sha256:corrupted";
957 let safe_name = LayerCache::digest_to_dirname(digest);
958
959 let layer_dir = tmp.path().join(&safe_name);
961 std::fs::create_dir_all(&layer_dir).unwrap();
962
963 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
965 std::fs::write(&meta_path, "not valid json!!!").unwrap();
966
967 let result = cache.get(digest).unwrap();
970 assert!(result.is_some());
971 }
972
973 #[test]
974 fn test_layer_cache_get_directory_without_metadata() {
975 let tmp = TempDir::new().unwrap();
976 let cache = LayerCache::new(tmp.path()).unwrap();
977 let digest = "sha256:no_meta";
978 let safe_name = LayerCache::digest_to_dirname(digest);
979
980 let layer_dir = tmp.path().join(&safe_name);
982 std::fs::create_dir_all(&layer_dir).unwrap();
983
984 let result = cache.get(digest).unwrap();
986 assert!(result.is_none());
987 }
988
989 #[test]
990 fn test_layer_cache_get_metadata_without_directory() {
991 let tmp = TempDir::new().unwrap();
992 let cache = LayerCache::new(tmp.path()).unwrap();
993 let digest = "sha256:no_dir";
994 let safe_name = LayerCache::digest_to_dirname(digest);
995
996 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
998 let meta = LayerMeta {
999 digest: digest.to_string(),
1000 size_bytes: 0,
1001 cached_at: 0,
1002 last_accessed: 0,
1003 };
1004 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1005
1006 let result = cache.get(digest).unwrap();
1008 assert!(result.is_none());
1009 }
1010
1011 #[test]
1012 fn test_layer_cache_put_source_not_exists() {
1013 let tmp = TempDir::new().unwrap();
1014 let cache = LayerCache::new(tmp.path()).unwrap();
1015
1016 let nonexistent = tmp.path().join("does_not_exist");
1017 let result = cache.put("sha256:bad_source", &nonexistent);
1018 assert!(result.is_err());
1019 }
1020
1021 #[test]
1022 fn test_layer_cache_prune_zero_limit() {
1023 let tmp = TempDir::new().unwrap();
1024 let cache = LayerCache::new(tmp.path()).unwrap();
1025
1026 let source = tmp.path().join("source");
1027 create_test_layer(&source, &[("f.txt", "data")]);
1028 cache.put("sha256:entry1", &source).unwrap();
1029 cache.put("sha256:entry2", &source).unwrap();
1030
1031 let evicted = cache.prune(0).unwrap();
1033 assert_eq!(evicted, 2);
1034 assert_eq!(cache.list_entries().unwrap().len(), 0);
1035 }
1036
1037 #[test]
1038 fn test_layer_cache_list_entries_ignores_non_meta_files() {
1039 let tmp = TempDir::new().unwrap();
1040 let cache = LayerCache::new(tmp.path()).unwrap();
1041
1042 let source = tmp.path().join("source");
1044 create_test_layer(&source, &[("f.txt", "data")]);
1045 cache.put("sha256:valid", &source).unwrap();
1046
1047 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1049 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1050
1051 let entries = cache.list_entries().unwrap();
1053 assert_eq!(entries.len(), 1);
1054 assert_eq!(entries[0].digest, "sha256:valid");
1055 }
1056
1057 #[test]
1058 fn test_layer_cache_list_entries_skips_invalid_json() {
1059 let tmp = TempDir::new().unwrap();
1060 let cache = LayerCache::new(tmp.path()).unwrap();
1061
1062 let source = tmp.path().join("source");
1064 create_test_layer(&source, &[("f.txt", "data")]);
1065 cache.put("sha256:valid", &source).unwrap();
1066
1067 std::fs::write(
1069 tmp.path().join("sha256_corrupted.meta.json"),
1070 "not json at all",
1071 )
1072 .unwrap();
1073
1074 let entries = cache.list_entries().unwrap();
1076 assert_eq!(entries.len(), 1);
1077 assert_eq!(entries[0].digest, "sha256:valid");
1078 }
1079
1080 #[test]
1081 fn test_layer_cache_put_preserves_file_content() {
1082 let tmp = TempDir::new().unwrap();
1083 let cache = LayerCache::new(tmp.path()).unwrap();
1084
1085 let source = tmp.path().join("source");
1086 create_test_layer(
1087 &source,
1088 &[
1089 ("binary.bin", "\x00\x01\x02\x03"),
1090 ("text.txt", "hello world\n"),
1091 ],
1092 );
1093
1094 let cached = cache.put("sha256:content_check", &source).unwrap();
1095
1096 assert_eq!(
1097 std::fs::read(cached.join("binary.bin")).unwrap(),
1098 b"\x00\x01\x02\x03"
1099 );
1100 assert_eq!(
1101 std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1102 "hello world\n"
1103 );
1104 }
1105
1106 #[test]
1107 fn test_layer_cache_multiple_colons_in_digest() {
1108 let tmp = TempDir::new().unwrap();
1109 let cache = LayerCache::new(tmp.path()).unwrap();
1110
1111 let digest = "sha256:abc:def:ghi";
1112 let source = tmp.path().join("source");
1113 create_test_layer(&source, &[("f.txt", "data")]);
1114
1115 cache.put(digest, &source).unwrap();
1116 let result = cache.get(digest).unwrap();
1117 assert!(result.is_some());
1118
1119 cache.invalidate(digest).unwrap();
1120 assert!(cache.get(digest).unwrap().is_none());
1121 }
1122
1123 #[test]
1124 fn test_copy_file_cow_preserves_content_and_mode() {
1125 let tmp = TempDir::new().unwrap();
1128 let src = tmp.path().join("src.bin");
1129 let dst = tmp.path().join("dst.bin");
1130 std::fs::write(&src, b"hello copy-on-write").unwrap();
1131 #[cfg(unix)]
1132 {
1133 use std::os::unix::fs::PermissionsExt;
1134 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1135 }
1136
1137 copy_file_cow(&src, &dst).unwrap();
1138
1139 assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1140 #[cfg(unix)]
1141 {
1142 use std::os::unix::fs::PermissionsExt;
1143 let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1144 assert_eq!(mode, 0o755, "executable bit must survive the copy");
1145 }
1146 }
1147
1148 #[test]
1149 fn test_copy_file_cow_overwrites_existing_dst() {
1150 let tmp = TempDir::new().unwrap();
1152 let src = tmp.path().join("src");
1153 let dst = tmp.path().join("dst");
1154 std::fs::write(&src, b"new").unwrap();
1155 std::fs::write(&dst, b"old-and-longer").unwrap();
1156 copy_file_cow(&src, &dst).unwrap();
1157 assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1158 }
1159}