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 let src_meta = std::fs::symlink_metadata(src).map_err(|e| {
277 BoxError::CacheError(format!(
278 "Failed to read directory metadata for {}: {}",
279 src.display(),
280 e
281 ))
282 })?;
283 preserve_owner(&src_meta, dst);
285
286 for entry in std::fs::read_dir(src).map_err(|e| {
287 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
288 })? {
289 let entry = entry
290 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
291 let src_path = entry.path();
292 let dst_path = dst.join(entry.file_name());
293
294 let meta = std::fs::symlink_metadata(&src_path).map_err(|e| {
296 BoxError::CacheError(format!(
297 "Failed to read metadata for {}: {}",
298 src_path.display(),
299 e
300 ))
301 })?;
302
303 if meta.is_symlink() {
304 #[cfg(unix)]
305 {
306 let target = std::fs::read_link(&src_path).map_err(|e| {
307 BoxError::CacheError(format!(
308 "Failed to read symlink {}: {}",
309 src_path.display(),
310 e
311 ))
312 })?;
313 std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
314 BoxError::CacheError(format!(
315 "Failed to create symlink {} -> {}: {}",
316 dst_path.display(),
317 target.display(),
318 e
319 ))
320 })?;
321 preserve_owner(&meta, &dst_path);
322 }
323 #[cfg(not(unix))]
324 {
325 return Err(BoxError::CacheError(format!(
326 "Symlink copy is not supported on this platform: {}",
327 src_path.display()
328 )));
329 }
330 } else if meta.is_dir() {
331 copy_dir_recursive(&src_path, &dst_path)?;
332 } else {
333 copy_file_cow(&src_path, &dst_path).map_err(|e| {
334 BoxError::CacheError(format!(
335 "Failed to copy {} to {}: {}",
336 src_path.display(),
337 dst_path.display(),
338 e
339 ))
340 })?;
341 preserve_owner(&meta, &dst_path);
342 }
343 }
344
345 std::fs::set_permissions(dst, src_meta.permissions()).map_err(|e| {
349 BoxError::CacheError(format!(
350 "Failed to preserve directory permissions on {}: {}",
351 dst.display(),
352 e
353 ))
354 })?;
355
356 Ok(())
357}
358
359#[cfg(target_os = "macos")]
360fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result<bool> {
361 use std::os::unix::ffi::OsStrExt;
362
363 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
364 std::io::Error::new(
365 std::io::ErrorKind::InvalidInput,
366 "source path contains a NUL byte",
367 )
368 })?;
369 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
370 std::io::Error::new(
371 std::io::ErrorKind::InvalidInput,
372 "destination path contains a NUL byte",
373 )
374 })?;
375 let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE;
376 let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) };
380 Ok(rc == 0)
381}
382
383pub(crate) fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
390 #[cfg(target_os = "linux")]
391 {
392 use std::os::unix::io::AsRawFd;
393 const FICLONE: libc::c_ulong = 0x4004_9409;
395 let reflinked = (|| -> std::io::Result<bool> {
396 let s = std::fs::File::open(src)?;
397 let d = std::fs::OpenOptions::new()
398 .write(true)
399 .create(true)
400 .truncate(true)
401 .open(dst)?;
402 let rc = unsafe { libc::ioctl(d.as_raw_fd(), FICLONE, s.as_raw_fd()) };
406 if rc != 0 {
407 return Ok(false);
408 }
409 if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
411 let _ = d.set_permissions(perm);
412 }
413 Ok(true)
414 })()
415 .unwrap_or(false);
416 if reflinked {
417 return Ok(());
418 }
419 }
420
421 #[cfg(target_os = "macos")]
422 {
423 use std::os::unix::ffi::OsStrExt;
424
425 let cloned = (|| -> std::io::Result<bool> {
426 let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| {
427 std::io::Error::new(
428 std::io::ErrorKind::InvalidInput,
429 "source path contains a NUL byte",
430 )
431 })?;
432 let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
433 std::io::Error::new(
434 std::io::ErrorKind::InvalidInput,
435 "destination path contains a NUL byte",
436 )
437 })?;
438
439 let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
443 if rc != 0 {
444 return Ok(false);
445 }
446 if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) {
447 let _ = std::fs::set_permissions(dst, perm);
448 }
449 Ok(true)
450 })()
451 .unwrap_or(false);
452 if cloned {
453 return Ok(());
454 }
455 }
456
457 std::fs::copy(src, dst).map(|_| ())
458}
459
460pub(crate) fn publish_dir_atomically(
474 source_dir: &Path,
475 dest_dir: &Path,
476 staging_parent: &Path,
477) -> Result<bool> {
478 if dest_dir.exists() {
479 return Ok(false);
480 }
481 let staging = tempfile::Builder::new()
482 .prefix(".staging-")
483 .tempdir_in(staging_parent)
484 .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
485 let staged = staging.path().join("d");
488 copy_dir_recursive(source_dir, &staged)?;
489
490 match std::fs::rename(&staged, dest_dir) {
491 Ok(()) => Ok(true),
492 Err(_) if dest_dir.exists() => Ok(false),
495 Err(e) => Err(BoxError::CacheError(format!(
496 "Failed to publish cache entry {}: {e}",
497 dest_dir.display()
498 ))),
499 }
500}
501
502pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
505 let parent = meta_path.parent().ok_or_else(|| {
506 BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
507 })?;
508 let mut tmp = tempfile::NamedTempFile::new_in(parent)
509 .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
510 use std::io::Write as _;
511 tmp.write_all(json.as_bytes())
512 .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
513 tmp.persist(meta_path)
514 .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
515 Ok(())
516}
517
518pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
520 let metadata = match std::fs::symlink_metadata(path) {
521 Ok(metadata) => metadata,
522 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
523 Err(error) => return Err(error),
524 };
525 if metadata.file_type().is_symlink() || metadata.is_file() {
526 return Ok(metadata.len());
527 }
528 if !metadata.is_dir() {
529 return Ok(0);
530 }
531
532 let mut total = 0_u64;
533 for entry in std::fs::read_dir(path)? {
534 total = total.saturating_add(dir_size(&entry?.path())?);
535 }
536 Ok(total)
537}
538
539impl CacheBackend for LayerCache {
540 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
541 self.get(key)
542 }
543
544 fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
545 self.put(key, source_dir)
546 }
547
548 fn invalidate(&self, key: &str) -> Result<()> {
549 self.invalidate(key)
550 }
551
552 fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
553 self.prune(max_bytes)
554 }
555
556 fn list(&self) -> Result<Vec<CacheEntry>> {
557 self.list_entries().map(|entries| {
558 entries
559 .into_iter()
560 .map(|m| CacheEntry {
561 key: m.digest,
562 description: String::new(),
563 size_bytes: m.size_bytes,
564 cached_at: m.cached_at,
565 last_accessed: m.last_accessed,
566 })
567 .collect()
568 })
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use tempfile::TempDir;
576
577 fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
578 std::fs::create_dir_all(dir).unwrap();
579 for (name, content) in files {
580 let file_path = dir.join(name);
581 if let Some(parent) = file_path.parent() {
582 std::fs::create_dir_all(parent).unwrap();
583 }
584 std::fs::write(&file_path, content).unwrap();
585 }
586 }
587
588 #[test]
589 fn test_layer_cache_new_creates_directory() {
590 let tmp = TempDir::new().unwrap();
591 let cache_dir = tmp.path().join("layers");
592
593 assert!(!cache_dir.exists());
594 let _cache = LayerCache::new(&cache_dir).unwrap();
595 assert!(cache_dir.is_dir());
596 }
597
598 #[test]
599 fn test_layer_cache_get_miss() {
600 let tmp = TempDir::new().unwrap();
601 let cache = LayerCache::new(tmp.path()).unwrap();
602
603 let result = cache.get("sha256:nonexistent").unwrap();
604 assert!(result.is_none());
605 }
606
607 #[test]
608 fn test_layer_cache_put_and_get() {
609 let tmp = TempDir::new().unwrap();
610 let cache = LayerCache::new(tmp.path()).unwrap();
611
612 let source = tmp.path().join("source_layer");
614 create_test_layer(
615 &source,
616 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
617 );
618
619 let digest = "sha256:abc123def456";
621 let cached_path = cache.put(digest, &source).unwrap();
622
623 assert!(cached_path.is_dir());
624 assert!(cached_path.join("file.txt").is_file());
625 assert!(cached_path.join("sub/nested.txt").is_file());
626
627 let result = cache.get(digest).unwrap();
629 assert!(result.is_some());
630 assert_eq!(result.unwrap(), cached_path);
631 }
632
633 #[test]
634 fn test_layer_cache_put_same_digest_is_idempotent() {
635 let tmp = TempDir::new().unwrap();
641 let cache = LayerCache::new(tmp.path()).unwrap();
642 let digest = "sha256:idempotent_test";
643
644 let source1 = tmp.path().join("v1");
645 create_test_layer(&source1, &[("v1.txt", "version 1")]);
646 let first = cache.put(digest, &source1).unwrap();
647
648 let source2 = tmp.path().join("v2");
649 create_test_layer(&source2, &[("v2.txt", "version 2")]);
650 let second = cache.put(digest, &source2).unwrap();
651
652 assert_eq!(first, second);
654 assert!(second.join("v1.txt").is_file());
655 assert!(!second.join("v2.txt").exists());
656 }
657
658 #[test]
659 fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
660 use std::sync::Arc;
661
662 let tmp = TempDir::new().unwrap();
663 let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
664 let digest = "sha256:concurrent_test";
665
666 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
669 let handles: Vec<_> = (0..12)
670 .map(|i| {
671 let cache = Arc::clone(&cache);
672 let src = tmp.path().join(format!("src{i}"));
673 create_test_layer(&src, files);
674 std::thread::spawn(move || cache.put(digest, &src).unwrap())
675 })
676 .collect();
677 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
678
679 for p in &paths {
682 assert_eq!(p, &paths[0]);
683 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
684 assert_eq!(
685 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
686 "beta"
687 );
688 }
689 assert!(cache.get(digest).unwrap().is_some());
690 }
691
692 #[test]
693 fn test_layer_cache_invalidate() {
694 let tmp = TempDir::new().unwrap();
695 let cache = LayerCache::new(tmp.path()).unwrap();
696 let digest = "sha256:to_invalidate";
697
698 let source = tmp.path().join("source");
699 create_test_layer(&source, &[("data.bin", "binary data")]);
700 cache.put(digest, &source).unwrap();
701
702 assert!(cache.get(digest).unwrap().is_some());
704
705 cache.invalidate(digest).unwrap();
707
708 assert!(cache.get(digest).unwrap().is_none());
710 }
711
712 #[test]
713 fn test_layer_cache_invalidate_nonexistent() {
714 let tmp = TempDir::new().unwrap();
715 let cache = LayerCache::new(tmp.path()).unwrap();
716
717 cache.invalidate("sha256:does_not_exist").unwrap();
719 }
720
721 #[test]
722 fn test_layer_cache_list_entries() {
723 let tmp = TempDir::new().unwrap();
724 let cache = LayerCache::new(tmp.path()).unwrap();
725
726 assert_eq!(cache.list_entries().unwrap().len(), 0);
728
729 let s1 = tmp.path().join("s1");
731 create_test_layer(&s1, &[("a.txt", "aaa")]);
732 cache.put("sha256:layer1", &s1).unwrap();
733
734 let s2 = tmp.path().join("s2");
735 create_test_layer(&s2, &[("b.txt", "bbb")]);
736 cache.put("sha256:layer2", &s2).unwrap();
737
738 let entries = cache.list_entries().unwrap();
739 assert_eq!(entries.len(), 2);
740
741 let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
742 assert!(digests.contains(&"sha256:layer1"));
743 assert!(digests.contains(&"sha256:layer2"));
744 }
745
746 #[test]
747 fn test_layer_cache_total_size() {
748 let tmp = TempDir::new().unwrap();
749 let cache = LayerCache::new(tmp.path()).unwrap();
750
751 assert_eq!(cache.total_size().unwrap(), 0);
752
753 let source = tmp.path().join("source");
754 create_test_layer(&source, &[("data.txt", "hello world")]);
755 cache.put("sha256:sized", &source).unwrap();
756
757 let total = cache.total_size().unwrap();
758 assert!(total > 0);
759 }
760
761 #[test]
762 fn test_layer_cache_prune_under_limit() {
763 let tmp = TempDir::new().unwrap();
764 let cache = LayerCache::new(tmp.path()).unwrap();
765
766 let source = tmp.path().join("source");
767 create_test_layer(&source, &[("small.txt", "tiny")]);
768 cache.put("sha256:small", &source).unwrap();
769
770 let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
772 assert_eq!(evicted, 0);
773 assert!(cache.get("sha256:small").unwrap().is_some());
774 }
775
776 #[test]
777 fn test_layer_cache_prune_evicts_oldest() {
778 let tmp = TempDir::new().unwrap();
779 let cache = LayerCache::new(tmp.path()).unwrap();
780
781 for i in 0..3 {
783 let source = tmp.path().join(format!("s{}", i));
784 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
786 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
787 std::thread::sleep(std::time::Duration::from_millis(10));
789 }
790
791 cache.get("sha256:layer2").unwrap();
793
794 let evicted = cache.prune(1).unwrap();
796 assert!(evicted >= 2);
797
798 }
801
802 #[test]
803 fn test_layer_cache_metadata_persists() {
804 let tmp = TempDir::new().unwrap();
805 let cache = LayerCache::new(tmp.path()).unwrap();
806 let digest = "sha256:meta_test";
807
808 let source = tmp.path().join("source");
809 create_test_layer(&source, &[("file.txt", "content")]);
810 cache.put(digest, &source).unwrap();
811
812 let meta_path = tmp.path().join("sha256_meta_test.meta.json");
814 assert!(meta_path.is_file());
815
816 let content = std::fs::read_to_string(&meta_path).unwrap();
817 let meta: LayerMeta = serde_json::from_str(&content).unwrap();
818
819 assert_eq!(meta.digest, digest);
820 assert!(meta.size_bytes > 0);
821 assert!(meta.cached_at > 0);
822 assert_eq!(meta.cached_at, meta.last_accessed);
823 }
824
825 #[test]
826 fn test_digest_to_dirname() {
827 assert_eq!(
828 LayerCache::digest_to_dirname("sha256:abc123"),
829 "sha256_abc123"
830 );
831 assert_eq!(
832 LayerCache::digest_to_dirname("plain_digest"),
833 "plain_digest"
834 );
835 }
836
837 #[test]
838 fn test_copy_dir_recursive() {
839 let tmp = TempDir::new().unwrap();
840 let src = tmp.path().join("src");
841 let dst = tmp.path().join("dst");
842
843 create_test_layer(
844 &src,
845 &[
846 ("a.txt", "aaa"),
847 ("sub/b.txt", "bbb"),
848 ("sub/deep/c.txt", "ccc"),
849 ],
850 );
851
852 copy_dir_recursive(&src, &dst).unwrap();
853
854 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
855 assert_eq!(
856 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
857 "bbb"
858 );
859 assert_eq!(
860 std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
861 "ccc"
862 );
863 }
864
865 #[cfg(unix)]
866 #[test]
867 fn test_copy_dir_recursive_preserves_symlinks() {
868 let tmp = TempDir::new().unwrap();
869 let src = tmp.path().join("src");
870 let dst = tmp.path().join("dst");
871 std::fs::create_dir_all(&src).unwrap();
872 std::fs::write(src.join("target.txt"), "target").unwrap();
873 std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap();
874
875 copy_dir_recursive(&src, &dst).unwrap();
876
877 let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap();
878 assert!(link_meta.file_type().is_symlink());
879 assert_eq!(
880 std::fs::read_link(dst.join("link.txt")).unwrap(),
881 std::path::PathBuf::from("target.txt")
882 );
883 }
884
885 #[test]
886 fn test_dir_size() {
887 let tmp = TempDir::new().unwrap();
888 let dir = tmp.path().join("sized");
889 create_test_layer(
890 &dir,
891 &[
892 ("a.txt", "hello"), ("sub/b.txt", "world"), ],
895 );
896
897 let size = dir_size(&dir).unwrap();
898 assert_eq!(size, 10);
899 }
900
901 #[cfg(unix)]
902 #[test]
903 fn test_dir_size_does_not_follow_external_directory_symlink() {
904 let tmp = TempDir::new().unwrap();
905 let dir = tmp.path().join("sized");
906 let outside = tmp.path().join("outside");
907 std::fs::create_dir_all(&dir).unwrap();
908 std::fs::create_dir_all(&outside).unwrap();
909 std::fs::write(dir.join("local"), b"local").unwrap();
910 std::fs::write(outside.join("host-data"), vec![0_u8; 4096]).unwrap();
911 let link = dir.join("external");
912 std::os::unix::fs::symlink(&outside, &link).unwrap();
913
914 let size = dir_size(&dir).unwrap();
915
916 assert_eq!(
917 size,
918 5 + std::fs::symlink_metadata(link).unwrap().len(),
919 "cache sizing must count the symlink itself without entering its target"
920 );
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn test_dir_size_does_not_follow_symlink_cycle() {
926 let tmp = TempDir::new().unwrap();
927 let dir = tmp.path().join("sized");
928 std::fs::create_dir_all(&dir).unwrap();
929 let link = dir.join("loop");
930 std::os::unix::fs::symlink(".", &link).unwrap();
931
932 let size = dir_size(&dir).unwrap();
933
934 assert_eq!(size, std::fs::symlink_metadata(link).unwrap().len());
935 }
936
937 #[test]
938 fn test_dir_size_empty_directory() {
939 let tmp = TempDir::new().unwrap();
940 let dir = tmp.path().join("empty");
941 std::fs::create_dir_all(&dir).unwrap();
942
943 let size = dir_size(&dir).unwrap();
944 assert_eq!(size, 0);
945 }
946
947 #[test]
948 fn test_dir_size_nonexistent_returns_zero() {
949 let tmp = TempDir::new().unwrap();
950 let dir = tmp.path().join("nonexistent");
951
952 let size = dir_size(&dir).unwrap();
954 assert_eq!(size, 0);
955 }
956
957 #[test]
958 fn test_copy_dir_recursive_empty_directory() {
959 let tmp = TempDir::new().unwrap();
960 let src = tmp.path().join("empty_src");
961 let dst = tmp.path().join("empty_dst");
962 std::fs::create_dir_all(&src).unwrap();
963
964 copy_dir_recursive(&src, &dst).unwrap();
965 assert!(dst.is_dir());
966 }
967
968 #[test]
969 fn test_copy_dir_recursive_source_not_exists() {
970 let tmp = TempDir::new().unwrap();
971 let src = tmp.path().join("nonexistent");
972 let dst = tmp.path().join("dst");
973
974 let result = copy_dir_recursive(&src, &dst);
975 assert!(result.is_err());
976 }
977
978 #[test]
979 fn test_layer_cache_get_updates_last_accessed() {
980 let tmp = TempDir::new().unwrap();
981 let cache = LayerCache::new(tmp.path()).unwrap();
982 let digest = "sha256:access_test";
983
984 let source = tmp.path().join("source");
985 create_test_layer(&source, &[("f.txt", "data")]);
986 cache.put(digest, &source).unwrap();
987
988 let meta_path = tmp.path().join("sha256_access_test.meta.json");
990 let content = std::fs::read_to_string(&meta_path).unwrap();
991 let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
992
993 std::thread::sleep(std::time::Duration::from_millis(10));
995
996 cache.get(digest).unwrap();
998
999 let content = std::fs::read_to_string(&meta_path).unwrap();
1001 let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
1002
1003 assert!(meta_after.last_accessed >= meta_before.last_accessed);
1004 assert_eq!(meta_after.cached_at, meta_before.cached_at);
1006 }
1007
1008 #[test]
1009 fn test_layer_cache_get_corrupted_metadata() {
1010 let tmp = TempDir::new().unwrap();
1011 let cache = LayerCache::new(tmp.path()).unwrap();
1012 let digest = "sha256:corrupted";
1013 let safe_name = LayerCache::digest_to_dirname(digest);
1014
1015 let layer_dir = tmp.path().join(&safe_name);
1017 std::fs::create_dir_all(&layer_dir).unwrap();
1018
1019 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1021 std::fs::write(&meta_path, "not valid json!!!").unwrap();
1022
1023 let result = cache.get(digest).unwrap();
1026 assert!(result.is_some());
1027 }
1028
1029 #[test]
1030 fn test_layer_cache_get_directory_without_metadata() {
1031 let tmp = TempDir::new().unwrap();
1032 let cache = LayerCache::new(tmp.path()).unwrap();
1033 let digest = "sha256:no_meta";
1034 let safe_name = LayerCache::digest_to_dirname(digest);
1035
1036 let layer_dir = tmp.path().join(&safe_name);
1038 std::fs::create_dir_all(&layer_dir).unwrap();
1039
1040 let result = cache.get(digest).unwrap();
1042 assert!(result.is_none());
1043 }
1044
1045 #[test]
1046 fn test_layer_cache_get_metadata_without_directory() {
1047 let tmp = TempDir::new().unwrap();
1048 let cache = LayerCache::new(tmp.path()).unwrap();
1049 let digest = "sha256:no_dir";
1050 let safe_name = LayerCache::digest_to_dirname(digest);
1051
1052 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
1054 let meta = LayerMeta {
1055 digest: digest.to_string(),
1056 size_bytes: 0,
1057 cached_at: 0,
1058 last_accessed: 0,
1059 };
1060 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
1061
1062 let result = cache.get(digest).unwrap();
1064 assert!(result.is_none());
1065 }
1066
1067 #[test]
1068 fn test_layer_cache_put_source_not_exists() {
1069 let tmp = TempDir::new().unwrap();
1070 let cache = LayerCache::new(tmp.path()).unwrap();
1071
1072 let nonexistent = tmp.path().join("does_not_exist");
1073 let result = cache.put("sha256:bad_source", &nonexistent);
1074 assert!(result.is_err());
1075 }
1076
1077 #[test]
1078 fn test_layer_cache_prune_zero_limit() {
1079 let tmp = TempDir::new().unwrap();
1080 let cache = LayerCache::new(tmp.path()).unwrap();
1081
1082 let source = tmp.path().join("source");
1083 create_test_layer(&source, &[("f.txt", "data")]);
1084 cache.put("sha256:entry1", &source).unwrap();
1085 cache.put("sha256:entry2", &source).unwrap();
1086
1087 let evicted = cache.prune(0).unwrap();
1089 assert_eq!(evicted, 2);
1090 assert_eq!(cache.list_entries().unwrap().len(), 0);
1091 }
1092
1093 #[test]
1094 fn test_layer_cache_list_entries_ignores_non_meta_files() {
1095 let tmp = TempDir::new().unwrap();
1096 let cache = LayerCache::new(tmp.path()).unwrap();
1097
1098 let source = tmp.path().join("source");
1100 create_test_layer(&source, &[("f.txt", "data")]);
1101 cache.put("sha256:valid", &source).unwrap();
1102
1103 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1105 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1106
1107 let entries = cache.list_entries().unwrap();
1109 assert_eq!(entries.len(), 1);
1110 assert_eq!(entries[0].digest, "sha256:valid");
1111 }
1112
1113 #[test]
1114 fn test_layer_cache_list_entries_skips_invalid_json() {
1115 let tmp = TempDir::new().unwrap();
1116 let cache = LayerCache::new(tmp.path()).unwrap();
1117
1118 let source = tmp.path().join("source");
1120 create_test_layer(&source, &[("f.txt", "data")]);
1121 cache.put("sha256:valid", &source).unwrap();
1122
1123 std::fs::write(
1125 tmp.path().join("sha256_corrupted.meta.json"),
1126 "not json at all",
1127 )
1128 .unwrap();
1129
1130 let entries = cache.list_entries().unwrap();
1132 assert_eq!(entries.len(), 1);
1133 assert_eq!(entries[0].digest, "sha256:valid");
1134 }
1135
1136 #[test]
1137 fn test_layer_cache_put_preserves_file_content() {
1138 let tmp = TempDir::new().unwrap();
1139 let cache = LayerCache::new(tmp.path()).unwrap();
1140
1141 let source = tmp.path().join("source");
1142 create_test_layer(
1143 &source,
1144 &[
1145 ("binary.bin", "\x00\x01\x02\x03"),
1146 ("text.txt", "hello world\n"),
1147 ],
1148 );
1149
1150 let cached = cache.put("sha256:content_check", &source).unwrap();
1151
1152 assert_eq!(
1153 std::fs::read(cached.join("binary.bin")).unwrap(),
1154 b"\x00\x01\x02\x03"
1155 );
1156 assert_eq!(
1157 std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1158 "hello world\n"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_layer_cache_multiple_colons_in_digest() {
1164 let tmp = TempDir::new().unwrap();
1165 let cache = LayerCache::new(tmp.path()).unwrap();
1166
1167 let digest = "sha256:abc:def:ghi";
1168 let source = tmp.path().join("source");
1169 create_test_layer(&source, &[("f.txt", "data")]);
1170
1171 cache.put(digest, &source).unwrap();
1172 let result = cache.get(digest).unwrap();
1173 assert!(result.is_some());
1174
1175 cache.invalidate(digest).unwrap();
1176 assert!(cache.get(digest).unwrap().is_none());
1177 }
1178
1179 #[test]
1180 fn test_copy_file_cow_preserves_content_and_mode() {
1181 let tmp = TempDir::new().unwrap();
1184 let src = tmp.path().join("src.bin");
1185 let dst = tmp.path().join("dst.bin");
1186 std::fs::write(&src, b"hello copy-on-write").unwrap();
1187 #[cfg(unix)]
1188 {
1189 use std::os::unix::fs::PermissionsExt;
1190 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1191 }
1192
1193 copy_file_cow(&src, &dst).unwrap();
1194
1195 assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1196 #[cfg(unix)]
1197 {
1198 use std::os::unix::fs::PermissionsExt;
1199 let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1200 assert_eq!(mode, 0o755, "executable bit must survive the copy");
1201 }
1202 }
1203
1204 #[cfg(unix)]
1205 #[test]
1206 fn test_copy_dir_recursive_preserves_directory_modes() {
1207 use std::os::unix::fs::PermissionsExt;
1208
1209 let tmp = TempDir::new().unwrap();
1210 let src = tmp.path().join("src");
1211 let nested = src.join("nested");
1212 let dst = tmp.path().join("dst");
1213 std::fs::create_dir_all(&nested).unwrap();
1214 std::fs::write(nested.join("file"), b"content").unwrap();
1215 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1216 std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o710)).unwrap();
1217
1218 copy_dir_recursive(&src, &dst).unwrap();
1219
1220 let root_mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1221 let nested_mode = std::fs::metadata(dst.join("nested"))
1222 .unwrap()
1223 .permissions()
1224 .mode()
1225 & 0o777;
1226 assert_eq!(root_mode, 0o755);
1227 assert_eq!(nested_mode, 0o710);
1228 }
1229
1230 #[test]
1231 fn test_copy_file_cow_overwrites_existing_dst() {
1232 let tmp = TempDir::new().unwrap();
1234 let src = tmp.path().join("src");
1235 let dst = tmp.path().join("dst");
1236 std::fs::write(&src, b"new").unwrap();
1237 std::fs::write(&dst, b"old-and-longer").unwrap();
1238 copy_file_cow(&src, &dst).unwrap();
1239 assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1240 }
1241}