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 std::fs::create_dir_all(dst).map_err(|e| {
259 BoxError::CacheError(format!(
260 "Failed to create directory {}: {}",
261 dst.display(),
262 e
263 ))
264 })?;
265 if let Ok(src_meta) = std::fs::symlink_metadata(src) {
267 preserve_owner(&src_meta, dst);
268 }
269
270 for entry in std::fs::read_dir(src).map_err(|e| {
271 BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e))
272 })? {
273 let entry = entry
274 .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?;
275 let src_path = entry.path();
276 let dst_path = dst.join(entry.file_name());
277
278 let meta = entry.metadata().map_err(|e| {
280 BoxError::CacheError(format!(
281 "Failed to read metadata for {}: {}",
282 src_path.display(),
283 e
284 ))
285 })?;
286
287 if meta.is_symlink() {
288 #[cfg(unix)]
289 {
290 let target = std::fs::read_link(&src_path).map_err(|e| {
291 BoxError::CacheError(format!(
292 "Failed to read symlink {}: {}",
293 src_path.display(),
294 e
295 ))
296 })?;
297 std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
298 BoxError::CacheError(format!(
299 "Failed to create symlink {} -> {}: {}",
300 dst_path.display(),
301 target.display(),
302 e
303 ))
304 })?;
305 preserve_owner(&meta, &dst_path);
306 }
307 #[cfg(not(unix))]
308 {
309 return Err(BoxError::CacheError(format!(
310 "Symlink copy is not supported on this platform: {}",
311 src_path.display()
312 )));
313 }
314 } else if meta.is_dir() {
315 copy_dir_recursive(&src_path, &dst_path)?;
316 } else {
317 copy_file_cow(&src_path, &dst_path).map_err(|e| {
318 BoxError::CacheError(format!(
319 "Failed to copy {} to {}: {}",
320 src_path.display(),
321 dst_path.display(),
322 e
323 ))
324 })?;
325 preserve_owner(&meta, &dst_path);
326 }
327 }
328
329 Ok(())
330}
331
332fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> {
339 #[cfg(target_os = "linux")]
340 {
341 use std::os::unix::io::AsRawFd;
342 const FICLONE: libc::c_ulong = 0x4004_9409;
344 let reflinked = (|| -> std::io::Result<bool> {
345 let s = std::fs::File::open(src)?;
346 let d = std::fs::OpenOptions::new()
347 .write(true)
348 .create(true)
349 .truncate(true)
350 .open(dst)?;
351 let rc = unsafe { libc::ioctl(d.as_raw_fd(), FICLONE, s.as_raw_fd()) };
355 if rc != 0 {
356 return Ok(false);
357 }
358 if let Ok(perm) = s.metadata().map(|m| m.permissions()) {
360 let _ = d.set_permissions(perm);
361 }
362 Ok(true)
363 })()
364 .unwrap_or(false);
365 if reflinked {
366 return Ok(());
367 }
368 }
369 std::fs::copy(src, dst).map(|_| ())
370}
371
372pub(crate) fn publish_dir_atomically(
386 source_dir: &Path,
387 dest_dir: &Path,
388 staging_parent: &Path,
389) -> Result<bool> {
390 if dest_dir.exists() {
391 return Ok(false);
392 }
393 let staging = tempfile::Builder::new()
394 .prefix(".staging-")
395 .tempdir_in(staging_parent)
396 .map_err(|e| BoxError::CacheError(format!("Failed to create staging dir: {e}")))?;
397 let staged = staging.path().join("d");
400 copy_dir_recursive(source_dir, &staged)?;
401
402 match std::fs::rename(&staged, dest_dir) {
403 Ok(()) => Ok(true),
404 Err(_) if dest_dir.exists() => Ok(false),
407 Err(e) => Err(BoxError::CacheError(format!(
408 "Failed to publish cache entry {}: {e}",
409 dest_dir.display()
410 ))),
411 }
412}
413
414pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> {
417 let parent = meta_path.parent().ok_or_else(|| {
418 BoxError::CacheError(format!("meta path has no parent: {}", meta_path.display()))
419 })?;
420 let mut tmp = tempfile::NamedTempFile::new_in(parent)
421 .map_err(|e| BoxError::CacheError(format!("Failed to stage metadata: {e}")))?;
422 use std::io::Write as _;
423 tmp.write_all(json.as_bytes())
424 .map_err(|e| BoxError::CacheError(format!("Failed to write metadata: {e}")))?;
425 tmp.persist(meta_path)
426 .map_err(|e| BoxError::CacheError(format!("Failed to persist metadata: {e}")))?;
427 Ok(())
428}
429
430pub(crate) fn dir_size(path: &Path) -> std::io::Result<u64> {
432 let mut total = 0;
433 if path.is_dir() {
434 for entry in std::fs::read_dir(path)? {
435 let entry = entry?;
436 let path = entry.path();
437 if path.is_dir() {
438 total += dir_size(&path)?;
439 } else {
440 total += entry.metadata()?.len();
441 }
442 }
443 }
444 Ok(total)
445}
446
447impl CacheBackend for LayerCache {
448 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
449 self.get(key)
450 }
451
452 fn put(&self, key: &str, source_dir: &Path, _description: &str) -> Result<PathBuf> {
453 self.put(key, source_dir)
454 }
455
456 fn invalidate(&self, key: &str) -> Result<()> {
457 self.invalidate(key)
458 }
459
460 fn prune(&self, _max_entries: usize, max_bytes: u64) -> Result<usize> {
461 self.prune(max_bytes)
462 }
463
464 fn list(&self) -> Result<Vec<CacheEntry>> {
465 self.list_entries().map(|entries| {
466 entries
467 .into_iter()
468 .map(|m| CacheEntry {
469 key: m.digest,
470 description: String::new(),
471 size_bytes: m.size_bytes,
472 cached_at: m.cached_at,
473 last_accessed: m.last_accessed,
474 })
475 .collect()
476 })
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use tempfile::TempDir;
484
485 fn create_test_layer(dir: &Path, files: &[(&str, &str)]) {
486 std::fs::create_dir_all(dir).unwrap();
487 for (name, content) in files {
488 let file_path = dir.join(name);
489 if let Some(parent) = file_path.parent() {
490 std::fs::create_dir_all(parent).unwrap();
491 }
492 std::fs::write(&file_path, content).unwrap();
493 }
494 }
495
496 #[test]
497 fn test_layer_cache_new_creates_directory() {
498 let tmp = TempDir::new().unwrap();
499 let cache_dir = tmp.path().join("layers");
500
501 assert!(!cache_dir.exists());
502 let _cache = LayerCache::new(&cache_dir).unwrap();
503 assert!(cache_dir.is_dir());
504 }
505
506 #[test]
507 fn test_layer_cache_get_miss() {
508 let tmp = TempDir::new().unwrap();
509 let cache = LayerCache::new(tmp.path()).unwrap();
510
511 let result = cache.get("sha256:nonexistent").unwrap();
512 assert!(result.is_none());
513 }
514
515 #[test]
516 fn test_layer_cache_put_and_get() {
517 let tmp = TempDir::new().unwrap();
518 let cache = LayerCache::new(tmp.path()).unwrap();
519
520 let source = tmp.path().join("source_layer");
522 create_test_layer(
523 &source,
524 &[("file.txt", "hello"), ("sub/nested.txt", "world")],
525 );
526
527 let digest = "sha256:abc123def456";
529 let cached_path = cache.put(digest, &source).unwrap();
530
531 assert!(cached_path.is_dir());
532 assert!(cached_path.join("file.txt").is_file());
533 assert!(cached_path.join("sub/nested.txt").is_file());
534
535 let result = cache.get(digest).unwrap();
537 assert!(result.is_some());
538 assert_eq!(result.unwrap(), cached_path);
539 }
540
541 #[test]
542 fn test_layer_cache_put_same_digest_is_idempotent() {
543 let tmp = TempDir::new().unwrap();
549 let cache = LayerCache::new(tmp.path()).unwrap();
550 let digest = "sha256:idempotent_test";
551
552 let source1 = tmp.path().join("v1");
553 create_test_layer(&source1, &[("v1.txt", "version 1")]);
554 let first = cache.put(digest, &source1).unwrap();
555
556 let source2 = tmp.path().join("v2");
557 create_test_layer(&source2, &[("v2.txt", "version 2")]);
558 let second = cache.put(digest, &source2).unwrap();
559
560 assert_eq!(first, second);
562 assert!(second.join("v1.txt").is_file());
563 assert!(!second.join("v2.txt").exists());
564 }
565
566 #[test]
567 fn test_layer_cache_concurrent_put_same_digest_no_corruption() {
568 use std::sync::Arc;
569
570 let tmp = TempDir::new().unwrap();
571 let cache = Arc::new(LayerCache::new(tmp.path()).unwrap());
572 let digest = "sha256:concurrent_test";
573
574 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
577 let handles: Vec<_> = (0..12)
578 .map(|i| {
579 let cache = Arc::clone(&cache);
580 let src = tmp.path().join(format!("src{i}"));
581 create_test_layer(&src, files);
582 std::thread::spawn(move || cache.put(digest, &src).unwrap())
583 })
584 .collect();
585 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
586
587 for p in &paths {
590 assert_eq!(p, &paths[0]);
591 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
592 assert_eq!(
593 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
594 "beta"
595 );
596 }
597 assert!(cache.get(digest).unwrap().is_some());
598 }
599
600 #[test]
601 fn test_layer_cache_invalidate() {
602 let tmp = TempDir::new().unwrap();
603 let cache = LayerCache::new(tmp.path()).unwrap();
604 let digest = "sha256:to_invalidate";
605
606 let source = tmp.path().join("source");
607 create_test_layer(&source, &[("data.bin", "binary data")]);
608 cache.put(digest, &source).unwrap();
609
610 assert!(cache.get(digest).unwrap().is_some());
612
613 cache.invalidate(digest).unwrap();
615
616 assert!(cache.get(digest).unwrap().is_none());
618 }
619
620 #[test]
621 fn test_layer_cache_invalidate_nonexistent() {
622 let tmp = TempDir::new().unwrap();
623 let cache = LayerCache::new(tmp.path()).unwrap();
624
625 cache.invalidate("sha256:does_not_exist").unwrap();
627 }
628
629 #[test]
630 fn test_layer_cache_list_entries() {
631 let tmp = TempDir::new().unwrap();
632 let cache = LayerCache::new(tmp.path()).unwrap();
633
634 assert_eq!(cache.list_entries().unwrap().len(), 0);
636
637 let s1 = tmp.path().join("s1");
639 create_test_layer(&s1, &[("a.txt", "aaa")]);
640 cache.put("sha256:layer1", &s1).unwrap();
641
642 let s2 = tmp.path().join("s2");
643 create_test_layer(&s2, &[("b.txt", "bbb")]);
644 cache.put("sha256:layer2", &s2).unwrap();
645
646 let entries = cache.list_entries().unwrap();
647 assert_eq!(entries.len(), 2);
648
649 let digests: Vec<&str> = entries.iter().map(|e| e.digest.as_str()).collect();
650 assert!(digests.contains(&"sha256:layer1"));
651 assert!(digests.contains(&"sha256:layer2"));
652 }
653
654 #[test]
655 fn test_layer_cache_total_size() {
656 let tmp = TempDir::new().unwrap();
657 let cache = LayerCache::new(tmp.path()).unwrap();
658
659 assert_eq!(cache.total_size().unwrap(), 0);
660
661 let source = tmp.path().join("source");
662 create_test_layer(&source, &[("data.txt", "hello world")]);
663 cache.put("sha256:sized", &source).unwrap();
664
665 let total = cache.total_size().unwrap();
666 assert!(total > 0);
667 }
668
669 #[test]
670 fn test_layer_cache_prune_under_limit() {
671 let tmp = TempDir::new().unwrap();
672 let cache = LayerCache::new(tmp.path()).unwrap();
673
674 let source = tmp.path().join("source");
675 create_test_layer(&source, &[("small.txt", "tiny")]);
676 cache.put("sha256:small", &source).unwrap();
677
678 let evicted = cache.prune(1024 * 1024 * 1024).unwrap();
680 assert_eq!(evicted, 0);
681 assert!(cache.get("sha256:small").unwrap().is_some());
682 }
683
684 #[test]
685 fn test_layer_cache_prune_evicts_oldest() {
686 let tmp = TempDir::new().unwrap();
687 let cache = LayerCache::new(tmp.path()).unwrap();
688
689 for i in 0..3 {
691 let source = tmp.path().join(format!("s{}", i));
692 create_test_layer(&source, &[("data.txt", &"x".repeat(100))]);
694 cache.put(&format!("sha256:layer{}", i), &source).unwrap();
695 std::thread::sleep(std::time::Duration::from_millis(10));
697 }
698
699 cache.get("sha256:layer2").unwrap();
701
702 let evicted = cache.prune(1).unwrap();
704 assert!(evicted >= 2);
705
706 }
709
710 #[test]
711 fn test_layer_cache_metadata_persists() {
712 let tmp = TempDir::new().unwrap();
713 let cache = LayerCache::new(tmp.path()).unwrap();
714 let digest = "sha256:meta_test";
715
716 let source = tmp.path().join("source");
717 create_test_layer(&source, &[("file.txt", "content")]);
718 cache.put(digest, &source).unwrap();
719
720 let meta_path = tmp.path().join("sha256_meta_test.meta.json");
722 assert!(meta_path.is_file());
723
724 let content = std::fs::read_to_string(&meta_path).unwrap();
725 let meta: LayerMeta = serde_json::from_str(&content).unwrap();
726
727 assert_eq!(meta.digest, digest);
728 assert!(meta.size_bytes > 0);
729 assert!(meta.cached_at > 0);
730 assert_eq!(meta.cached_at, meta.last_accessed);
731 }
732
733 #[test]
734 fn test_digest_to_dirname() {
735 assert_eq!(
736 LayerCache::digest_to_dirname("sha256:abc123"),
737 "sha256_abc123"
738 );
739 assert_eq!(
740 LayerCache::digest_to_dirname("plain_digest"),
741 "plain_digest"
742 );
743 }
744
745 #[test]
746 fn test_copy_dir_recursive() {
747 let tmp = TempDir::new().unwrap();
748 let src = tmp.path().join("src");
749 let dst = tmp.path().join("dst");
750
751 create_test_layer(
752 &src,
753 &[
754 ("a.txt", "aaa"),
755 ("sub/b.txt", "bbb"),
756 ("sub/deep/c.txt", "ccc"),
757 ],
758 );
759
760 copy_dir_recursive(&src, &dst).unwrap();
761
762 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
763 assert_eq!(
764 std::fs::read_to_string(dst.join("sub/b.txt")).unwrap(),
765 "bbb"
766 );
767 assert_eq!(
768 std::fs::read_to_string(dst.join("sub/deep/c.txt")).unwrap(),
769 "ccc"
770 );
771 }
772
773 #[test]
774 fn test_dir_size() {
775 let tmp = TempDir::new().unwrap();
776 let dir = tmp.path().join("sized");
777 create_test_layer(
778 &dir,
779 &[
780 ("a.txt", "hello"), ("sub/b.txt", "world"), ],
783 );
784
785 let size = dir_size(&dir).unwrap();
786 assert_eq!(size, 10);
787 }
788
789 #[test]
790 fn test_dir_size_empty_directory() {
791 let tmp = TempDir::new().unwrap();
792 let dir = tmp.path().join("empty");
793 std::fs::create_dir_all(&dir).unwrap();
794
795 let size = dir_size(&dir).unwrap();
796 assert_eq!(size, 0);
797 }
798
799 #[test]
800 fn test_dir_size_nonexistent_returns_zero() {
801 let tmp = TempDir::new().unwrap();
802 let dir = tmp.path().join("nonexistent");
803
804 let size = dir_size(&dir).unwrap();
806 assert_eq!(size, 0);
807 }
808
809 #[test]
810 fn test_copy_dir_recursive_empty_directory() {
811 let tmp = TempDir::new().unwrap();
812 let src = tmp.path().join("empty_src");
813 let dst = tmp.path().join("empty_dst");
814 std::fs::create_dir_all(&src).unwrap();
815
816 copy_dir_recursive(&src, &dst).unwrap();
817 assert!(dst.is_dir());
818 }
819
820 #[test]
821 fn test_copy_dir_recursive_source_not_exists() {
822 let tmp = TempDir::new().unwrap();
823 let src = tmp.path().join("nonexistent");
824 let dst = tmp.path().join("dst");
825
826 let result = copy_dir_recursive(&src, &dst);
827 assert!(result.is_err());
828 }
829
830 #[test]
831 fn test_layer_cache_get_updates_last_accessed() {
832 let tmp = TempDir::new().unwrap();
833 let cache = LayerCache::new(tmp.path()).unwrap();
834 let digest = "sha256:access_test";
835
836 let source = tmp.path().join("source");
837 create_test_layer(&source, &[("f.txt", "data")]);
838 cache.put(digest, &source).unwrap();
839
840 let meta_path = tmp.path().join("sha256_access_test.meta.json");
842 let content = std::fs::read_to_string(&meta_path).unwrap();
843 let meta_before: LayerMeta = serde_json::from_str(&content).unwrap();
844
845 std::thread::sleep(std::time::Duration::from_millis(10));
847
848 cache.get(digest).unwrap();
850
851 let content = std::fs::read_to_string(&meta_path).unwrap();
853 let meta_after: LayerMeta = serde_json::from_str(&content).unwrap();
854
855 assert!(meta_after.last_accessed >= meta_before.last_accessed);
856 assert_eq!(meta_after.cached_at, meta_before.cached_at);
858 }
859
860 #[test]
861 fn test_layer_cache_get_corrupted_metadata() {
862 let tmp = TempDir::new().unwrap();
863 let cache = LayerCache::new(tmp.path()).unwrap();
864 let digest = "sha256:corrupted";
865 let safe_name = LayerCache::digest_to_dirname(digest);
866
867 let layer_dir = tmp.path().join(&safe_name);
869 std::fs::create_dir_all(&layer_dir).unwrap();
870
871 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
873 std::fs::write(&meta_path, "not valid json!!!").unwrap();
874
875 let result = cache.get(digest).unwrap();
878 assert!(result.is_some());
879 }
880
881 #[test]
882 fn test_layer_cache_get_directory_without_metadata() {
883 let tmp = TempDir::new().unwrap();
884 let cache = LayerCache::new(tmp.path()).unwrap();
885 let digest = "sha256:no_meta";
886 let safe_name = LayerCache::digest_to_dirname(digest);
887
888 let layer_dir = tmp.path().join(&safe_name);
890 std::fs::create_dir_all(&layer_dir).unwrap();
891
892 let result = cache.get(digest).unwrap();
894 assert!(result.is_none());
895 }
896
897 #[test]
898 fn test_layer_cache_get_metadata_without_directory() {
899 let tmp = TempDir::new().unwrap();
900 let cache = LayerCache::new(tmp.path()).unwrap();
901 let digest = "sha256:no_dir";
902 let safe_name = LayerCache::digest_to_dirname(digest);
903
904 let meta_path = tmp.path().join(format!("{}.meta.json", safe_name));
906 let meta = LayerMeta {
907 digest: digest.to_string(),
908 size_bytes: 0,
909 cached_at: 0,
910 last_accessed: 0,
911 };
912 std::fs::write(&meta_path, serde_json::to_string(&meta).unwrap()).unwrap();
913
914 let result = cache.get(digest).unwrap();
916 assert!(result.is_none());
917 }
918
919 #[test]
920 fn test_layer_cache_put_source_not_exists() {
921 let tmp = TempDir::new().unwrap();
922 let cache = LayerCache::new(tmp.path()).unwrap();
923
924 let nonexistent = tmp.path().join("does_not_exist");
925 let result = cache.put("sha256:bad_source", &nonexistent);
926 assert!(result.is_err());
927 }
928
929 #[test]
930 fn test_layer_cache_prune_zero_limit() {
931 let tmp = TempDir::new().unwrap();
932 let cache = LayerCache::new(tmp.path()).unwrap();
933
934 let source = tmp.path().join("source");
935 create_test_layer(&source, &[("f.txt", "data")]);
936 cache.put("sha256:entry1", &source).unwrap();
937 cache.put("sha256:entry2", &source).unwrap();
938
939 let evicted = cache.prune(0).unwrap();
941 assert_eq!(evicted, 2);
942 assert_eq!(cache.list_entries().unwrap().len(), 0);
943 }
944
945 #[test]
946 fn test_layer_cache_list_entries_ignores_non_meta_files() {
947 let tmp = TempDir::new().unwrap();
948 let cache = LayerCache::new(tmp.path()).unwrap();
949
950 let source = tmp.path().join("source");
952 create_test_layer(&source, &[("f.txt", "data")]);
953 cache.put("sha256:valid", &source).unwrap();
954
955 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
957 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
958
959 let entries = cache.list_entries().unwrap();
961 assert_eq!(entries.len(), 1);
962 assert_eq!(entries[0].digest, "sha256:valid");
963 }
964
965 #[test]
966 fn test_layer_cache_list_entries_skips_invalid_json() {
967 let tmp = TempDir::new().unwrap();
968 let cache = LayerCache::new(tmp.path()).unwrap();
969
970 let source = tmp.path().join("source");
972 create_test_layer(&source, &[("f.txt", "data")]);
973 cache.put("sha256:valid", &source).unwrap();
974
975 std::fs::write(
977 tmp.path().join("sha256_corrupted.meta.json"),
978 "not json at all",
979 )
980 .unwrap();
981
982 let entries = cache.list_entries().unwrap();
984 assert_eq!(entries.len(), 1);
985 assert_eq!(entries[0].digest, "sha256:valid");
986 }
987
988 #[test]
989 fn test_layer_cache_put_preserves_file_content() {
990 let tmp = TempDir::new().unwrap();
991 let cache = LayerCache::new(tmp.path()).unwrap();
992
993 let source = tmp.path().join("source");
994 create_test_layer(
995 &source,
996 &[
997 ("binary.bin", "\x00\x01\x02\x03"),
998 ("text.txt", "hello world\n"),
999 ],
1000 );
1001
1002 let cached = cache.put("sha256:content_check", &source).unwrap();
1003
1004 assert_eq!(
1005 std::fs::read(cached.join("binary.bin")).unwrap(),
1006 b"\x00\x01\x02\x03"
1007 );
1008 assert_eq!(
1009 std::fs::read_to_string(cached.join("text.txt")).unwrap(),
1010 "hello world\n"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_layer_cache_multiple_colons_in_digest() {
1016 let tmp = TempDir::new().unwrap();
1017 let cache = LayerCache::new(tmp.path()).unwrap();
1018
1019 let digest = "sha256:abc:def:ghi";
1020 let source = tmp.path().join("source");
1021 create_test_layer(&source, &[("f.txt", "data")]);
1022
1023 cache.put(digest, &source).unwrap();
1024 let result = cache.get(digest).unwrap();
1025 assert!(result.is_some());
1026
1027 cache.invalidate(digest).unwrap();
1028 assert!(cache.get(digest).unwrap().is_none());
1029 }
1030
1031 #[test]
1032 fn test_copy_file_cow_preserves_content_and_mode() {
1033 let tmp = TempDir::new().unwrap();
1036 let src = tmp.path().join("src.bin");
1037 let dst = tmp.path().join("dst.bin");
1038 std::fs::write(&src, b"hello copy-on-write").unwrap();
1039 #[cfg(unix)]
1040 {
1041 use std::os::unix::fs::PermissionsExt;
1042 std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
1043 }
1044
1045 copy_file_cow(&src, &dst).unwrap();
1046
1047 assert_eq!(std::fs::read(&dst).unwrap(), b"hello copy-on-write");
1048 #[cfg(unix)]
1049 {
1050 use std::os::unix::fs::PermissionsExt;
1051 let mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777;
1052 assert_eq!(mode, 0o755, "executable bit must survive the copy");
1053 }
1054 }
1055
1056 #[test]
1057 fn test_copy_file_cow_overwrites_existing_dst() {
1058 let tmp = TempDir::new().unwrap();
1060 let src = tmp.path().join("src");
1061 let dst = tmp.path().join("dst");
1062 std::fs::write(&src, b"new").unwrap();
1063 std::fs::write(&dst, b"old-and-longer").unwrap();
1064 copy_file_cow(&src, &dst).unwrap();
1065 assert_eq!(std::fs::read(&dst).unwrap(), b"new");
1066 }
1067}