1use std::path::{Path, PathBuf};
8
9use a3s_box_core::error::{BoxError, Result};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
15pub struct RootfsPruneResult {
16 pub entries_removed: usize,
17 pub bytes_freed: u64,
18}
19
20impl RootfsPruneResult {
21 pub fn merge(&mut self, other: Self) {
22 self.entries_removed = self.entries_removed.saturating_add(other.entries_removed);
23 self.bytes_freed = self.bytes_freed.saturating_add(other.bytes_freed);
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct RootfsMeta {
30 pub key: String,
32 pub description: String,
34 pub size_bytes: u64,
36 pub cached_at: i64,
38 pub last_accessed: i64,
40}
41
42pub struct RootfsCache {
47 cache_dir: PathBuf,
49}
50
51impl RootfsCache {
52 pub fn new(cache_dir: &Path) -> Result<Self> {
54 std::fs::create_dir_all(cache_dir).map_err(|e| {
55 BoxError::CacheError(format!(
56 "Failed to create rootfs cache directory {}: {}",
57 cache_dir.display(),
58 e
59 ))
60 })?;
61
62 Ok(Self {
63 cache_dir: cache_dir.to_path_buf(),
64 })
65 }
66
67 pub fn compute_key(
75 image_ref: &str,
76 layer_digests: &[String],
77 entrypoint: &[String],
78 env: &[(String, String)],
79 ) -> String {
80 let mut hasher = Sha256::new();
81 hasher.update(b"rootfs-cache-v2\n");
85 hasher.update(image_ref.as_bytes());
86 hasher.update(b"\n");
87
88 for digest in layer_digests {
89 hasher.update(digest.as_bytes());
90 hasher.update(b"\n");
91 }
92
93 for part in entrypoint {
94 hasher.update(part.as_bytes());
95 hasher.update(b"\n");
96 }
97
98 let mut sorted_env: Vec<_> = env.to_vec();
99 sorted_env.sort();
100 for (k, v) in &sorted_env {
101 hasher.update(k.as_bytes());
102 hasher.update(b"=");
103 hasher.update(v.as_bytes());
104 hasher.update(b"\n");
105 }
106
107 hex::encode(hasher.finalize())
108 }
109
110 pub fn compute_image_key(image_ref: &str, manifest_digest: &str) -> String {
117 Self::compute_key(image_ref, &[manifest_digest.to_string()], &[], &[])
118 }
119
120 pub fn get(&self, key: &str) -> Result<Option<PathBuf>> {
124 let rootfs_dir = self.cache_dir.join(key);
125 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
126
127 if !rootfs_dir.is_dir() || !meta_path.is_file() {
128 return Ok(None);
129 }
130
131 if let Ok(content) = std::fs::read_to_string(&meta_path) {
133 if let Ok(mut meta) = serde_json::from_str::<RootfsMeta>(&content) {
134 meta.last_accessed = chrono::Utc::now().timestamp();
135 if let Err(e) = super::layer_cache::write_meta_atomically(
136 &meta_path,
137 &serde_json::to_string_pretty(&meta)?,
138 ) {
139 tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update rootfs cache metadata");
140 }
141 }
142 }
143
144 Ok(Some(rootfs_dir))
145 }
146
147 pub fn put(&self, key: &str, source_rootfs: &Path, description: &str) -> Result<PathBuf> {
152 let rootfs_dir = self.cache_dir.join(key);
153 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
154
155 if rootfs_dir.is_dir() && meta_path.is_file() {
160 return Ok(rootfs_dir);
161 }
162
163 super::layer_cache::publish_dir_atomically(source_rootfs, &rootfs_dir, &self.cache_dir)?;
167
168 let size_bytes = super::layer_cache::dir_size(&rootfs_dir).unwrap_or(0);
170
171 let now = chrono::Utc::now().timestamp();
173 let meta = RootfsMeta {
174 key: key.to_string(),
175 description: description.to_string(),
176 size_bytes,
177 cached_at: now,
178 last_accessed: now,
179 };
180 super::layer_cache::write_meta_atomically(
181 &meta_path,
182 &serde_json::to_string_pretty(&meta)?,
183 )?;
184
185 tracing::debug!(
186 key = %key,
187 description = %description,
188 size_bytes,
189 path = %rootfs_dir.display(),
190 "Cached rootfs"
191 );
192
193 Ok(rootfs_dir)
194 }
195
196 pub fn invalidate(&self, key: &str) -> Result<()> {
198 let rootfs_dir = self.cache_dir.join(key);
199 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
200
201 if rootfs_dir.exists() {
202 std::fs::remove_dir_all(&rootfs_dir).map_err(|e| {
203 BoxError::CacheError(format!(
204 "Failed to remove cached rootfs {}: {}",
205 rootfs_dir.display(),
206 e
207 ))
208 })?;
209 }
210 if meta_path.exists() {
211 std::fs::remove_file(&meta_path).map_err(|e| {
212 BoxError::CacheError(format!(
213 "Failed to remove rootfs metadata {}: {}",
214 meta_path.display(),
215 e
216 ))
217 })?;
218 }
219
220 Ok(())
221 }
222
223 pub fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
227 self.prune_protecting(max_entries, max_bytes, &std::collections::HashSet::new())
228 }
229
230 pub fn prune_protecting(
239 &self,
240 max_entries: usize,
241 max_bytes: u64,
242 protected: &std::collections::HashSet<String>,
243 ) -> Result<usize> {
244 let mut entries = self.list_entries()?;
245
246 if entries.len() <= max_entries {
247 let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
248 if total_size <= max_bytes {
249 return Ok(0);
250 }
251 }
252
253 entries.sort_by_key(|e| e.last_accessed);
255
256 let mut current_count = entries.len();
257 let mut current_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
258 let mut evicted = 0;
259
260 for entry in &entries {
261 if current_count <= max_entries && current_size <= max_bytes {
262 break;
263 }
264 if protected.contains(&entry.key) {
267 continue;
268 }
269 self.invalidate(&entry.key)?;
270 current_count -= 1;
271 current_size = current_size.saturating_sub(entry.size_bytes);
272 evicted += 1;
273
274 tracing::debug!(
275 key = %entry.key,
276 description = %entry.description,
277 size_bytes = entry.size_bytes,
278 "Evicted cached rootfs"
279 );
280 }
281
282 Ok(evicted)
283 }
284
285 pub fn prune_all_protecting(
292 &self,
293 protected: &std::collections::HashSet<String>,
294 ) -> Result<RootfsPruneResult> {
295 let mut keys = std::collections::BTreeSet::new();
296 for entry in std::fs::read_dir(&self.cache_dir).map_err(|error| {
297 BoxError::CacheError(format!(
298 "Failed to read rootfs cache directory {}: {error}",
299 self.cache_dir.display()
300 ))
301 })? {
302 let entry = entry.map_err(|error| {
303 BoxError::CacheError(format!("Failed to read rootfs cache entry: {error}"))
304 })?;
305 let name = entry.file_name().to_string_lossy().into_owned();
306 if name.starts_with('.') || name.ends_with(".meta.json.lock") {
307 continue;
308 }
309 let key = name.strip_suffix(".meta.json").unwrap_or(&name);
310 keys.insert(key.to_string());
311 }
312
313 let mut result = RootfsPruneResult::default();
314 for key in keys {
315 if protected.contains(&key) {
316 continue;
317 }
318 let paths = [
319 self.cache_dir.join(&key),
320 self.cache_dir.join(format!("{key}.meta.json")),
321 ];
322 let mut removed = false;
323 for path in paths {
324 let Some(size) = removable_path_size(&path)? else {
325 continue;
326 };
327 remove_path_no_follow(&path)?;
328 result.bytes_freed = result.bytes_freed.saturating_add(size);
329 removed = true;
330 }
331 if removed {
332 result.entries_removed = result.entries_removed.saturating_add(1);
333 }
334 }
335 Ok(result)
336 }
337
338 pub fn list_entries(&self) -> Result<Vec<RootfsMeta>> {
340 let mut entries = Vec::new();
341
342 let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
343 BoxError::CacheError(format!(
344 "Failed to read rootfs cache directory {}: {}",
345 self.cache_dir.display(),
346 e
347 ))
348 })?;
349
350 for entry in read_dir {
351 let entry = entry.map_err(|e| {
352 BoxError::CacheError(format!("Failed to read directory entry: {}", e))
353 })?;
354 let path = entry.path();
355
356 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
357 if name.ends_with(".meta.json") {
358 if let Ok(content) = std::fs::read_to_string(&path) {
359 if let Ok(meta) = serde_json::from_str::<RootfsMeta>(&content) {
360 entries.push(meta);
361 }
362 }
363 }
364 }
365 }
366
367 Ok(entries)
368 }
369
370 pub fn total_size(&self) -> Result<u64> {
372 Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
373 }
374
375 pub fn entry_count(&self) -> Result<usize> {
377 Ok(self.list_entries()?.len())
378 }
379}
380
381pub fn prune_apfs_rootfs_cache_all(
387 cache_dir: &Path,
388 protected: &std::collections::HashSet<String>,
389) -> Result<RootfsPruneResult> {
390 if !cache_dir.exists() {
391 return Ok(RootfsPruneResult::default());
392 }
393 let mut result = RootfsPruneResult::default();
394 for entry in std::fs::read_dir(cache_dir).map_err(|error| {
395 BoxError::CacheError(format!(
396 "Failed to read APFS rootfs cache directory {}: {error}",
397 cache_dir.display()
398 ))
399 })? {
400 let entry = entry.map_err(|error| {
401 BoxError::CacheError(format!("Failed to read APFS rootfs cache entry: {error}"))
402 })?;
403 let name = entry.file_name().to_string_lossy().into_owned();
404 if name.starts_with('.') {
405 continue;
406 }
407 let Some(key) = name.strip_suffix(".sparseimage") else {
408 continue;
409 };
410 if protected.contains(key) {
411 continue;
412 }
413 let path = entry.path();
414 let Some(size) = removable_path_size(&path)? else {
415 continue;
416 };
417 remove_path_no_follow(&path)?;
418 result.entries_removed = result.entries_removed.saturating_add(1);
419 result.bytes_freed = result.bytes_freed.saturating_add(size);
420 }
421 Ok(result)
422}
423
424fn removable_path_size(path: &Path) -> Result<Option<u64>> {
425 match std::fs::symlink_metadata(path) {
426 Ok(_) => super::layer_cache::dir_size(path)
427 .map(Some)
428 .map_err(|error| {
429 BoxError::CacheError(format!(
430 "Failed to measure cached path {}: {error}",
431 path.display()
432 ))
433 }),
434 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
435 Err(error) => Err(BoxError::CacheError(format!(
436 "Failed to inspect cached path {}: {error}",
437 path.display()
438 ))),
439 }
440}
441
442fn remove_path_no_follow(path: &Path) -> Result<()> {
443 let metadata = match std::fs::symlink_metadata(path) {
444 Ok(metadata) => metadata,
445 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
446 Err(error) => {
447 return Err(BoxError::CacheError(format!(
448 "Failed to inspect cached path {}: {error}",
449 path.display()
450 )))
451 }
452 };
453 let removed = if metadata.is_dir() && !metadata.file_type().is_symlink() {
454 std::fs::remove_dir_all(path)
455 } else {
456 std::fs::remove_file(path)
457 };
458 match removed {
459 Ok(()) => Ok(()),
460 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
461 Err(error) => Err(BoxError::CacheError(format!(
462 "Failed to remove cached path {}: {error}",
463 path.display()
464 ))),
465 }
466}
467
468impl a3s_box_core::traits::CacheBackend for RootfsCache {
469 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
470 self.get(key)
471 }
472
473 fn put(&self, key: &str, source_dir: &Path, description: &str) -> Result<PathBuf> {
474 self.put(key, source_dir, description)
475 }
476
477 fn invalidate(&self, key: &str) -> Result<()> {
478 self.invalidate(key)
479 }
480
481 fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
482 self.prune(max_entries, max_bytes)
483 }
484
485 fn list(&self) -> Result<Vec<a3s_box_core::traits::CacheEntry>> {
486 self.list_entries().map(|entries| {
487 entries
488 .into_iter()
489 .map(|m| a3s_box_core::traits::CacheEntry {
490 key: m.key,
491 description: m.description,
492 size_bytes: m.size_bytes,
493 cached_at: m.cached_at,
494 last_accessed: m.last_accessed,
495 })
496 .collect()
497 })
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use tempfile::TempDir;
505
506 fn create_test_rootfs(dir: &Path, files: &[(&str, &str)]) {
507 std::fs::create_dir_all(dir).unwrap();
508 for (name, content) in files {
509 let file_path = dir.join(name);
510 if let Some(parent) = file_path.parent() {
511 std::fs::create_dir_all(parent).unwrap();
512 }
513 std::fs::write(&file_path, content).unwrap();
514 }
515 }
516
517 #[test]
518 fn test_rootfs_cache_new_creates_directory() {
519 let tmp = TempDir::new().unwrap();
520 let cache_dir = tmp.path().join("rootfs");
521
522 assert!(!cache_dir.exists());
523 let _cache = RootfsCache::new(&cache_dir).unwrap();
524 assert!(cache_dir.is_dir());
525 }
526
527 #[test]
528 fn test_rootfs_cache_get_miss() {
529 let tmp = TempDir::new().unwrap();
530 let cache = RootfsCache::new(tmp.path()).unwrap();
531
532 let result = cache.get("nonexistent_key").unwrap();
533 assert!(result.is_none());
534 }
535
536 #[test]
537 fn test_rootfs_cache_put_and_get() {
538 let tmp = TempDir::new().unwrap();
539 let cache = RootfsCache::new(tmp.path()).unwrap();
540
541 let source = tmp.path().join("source_rootfs");
542 create_test_rootfs(
543 &source,
544 &[("bin/agent", "binary"), ("etc/config.json", "{}")],
545 );
546
547 let key = "abc123def456";
548 let cached_path = cache.put(key, &source, "test rootfs").unwrap();
549
550 assert!(cached_path.is_dir());
551 assert!(cached_path.join("bin/agent").is_file());
552 assert!(cached_path.join("etc/config.json").is_file());
553
554 let result = cache.get(key).unwrap();
555 assert!(result.is_some());
556 assert_eq!(result.unwrap(), cached_path);
557 }
558
559 #[test]
560 fn test_rootfs_cache_invalidate() {
561 let tmp = TempDir::new().unwrap();
562 let cache = RootfsCache::new(tmp.path()).unwrap();
563 let key = "to_invalidate";
564
565 let source = tmp.path().join("source");
566 create_test_rootfs(&source, &[("data.bin", "data")]);
567 cache.put(key, &source, "temp").unwrap();
568
569 assert!(cache.get(key).unwrap().is_some());
570 cache.invalidate(key).unwrap();
571 assert!(cache.get(key).unwrap().is_none());
572 }
573
574 #[test]
575 fn test_rootfs_cache_invalidate_nonexistent() {
576 let tmp = TempDir::new().unwrap();
577 let cache = RootfsCache::new(tmp.path()).unwrap();
578 cache.invalidate("does_not_exist").unwrap();
579 }
580
581 #[test]
582 fn test_rootfs_cache_list_entries() {
583 let tmp = TempDir::new().unwrap();
584 let cache = RootfsCache::new(tmp.path()).unwrap();
585
586 assert_eq!(cache.list_entries().unwrap().len(), 0);
587
588 let s1 = tmp.path().join("s1");
589 create_test_rootfs(&s1, &[("a.txt", "aaa")]);
590 cache.put("key1", &s1, "first").unwrap();
591
592 let s2 = tmp.path().join("s2");
593 create_test_rootfs(&s2, &[("b.txt", "bbb")]);
594 cache.put("key2", &s2, "second").unwrap();
595
596 let entries = cache.list_entries().unwrap();
597 assert_eq!(entries.len(), 2);
598
599 let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
600 assert!(keys.contains(&"key1"));
601 assert!(keys.contains(&"key2"));
602 }
603
604 #[test]
605 fn test_rootfs_cache_entry_count() {
606 let tmp = TempDir::new().unwrap();
607 let cache = RootfsCache::new(tmp.path()).unwrap();
608
609 assert_eq!(cache.entry_count().unwrap(), 0);
610
611 let source = tmp.path().join("source");
612 create_test_rootfs(&source, &[("f.txt", "data")]);
613 cache.put("k1", &source, "one").unwrap();
614 cache.put("k2", &source, "two").unwrap();
615
616 assert_eq!(cache.entry_count().unwrap(), 2);
617 }
618
619 #[test]
620 fn test_rootfs_cache_total_size() {
621 let tmp = TempDir::new().unwrap();
622 let cache = RootfsCache::new(tmp.path()).unwrap();
623
624 assert_eq!(cache.total_size().unwrap(), 0);
625
626 let source = tmp.path().join("source");
627 create_test_rootfs(&source, &[("data.txt", "hello world")]);
628 cache.put("sized", &source, "sized entry").unwrap();
629
630 assert!(cache.total_size().unwrap() > 0);
631 }
632
633 #[test]
634 fn test_rootfs_cache_prune_by_count() {
635 let tmp = TempDir::new().unwrap();
636 let cache = RootfsCache::new(tmp.path()).unwrap();
637
638 for i in 0..5 {
640 let source = tmp.path().join(format!("s{}", i));
641 create_test_rootfs(&source, &[("f.txt", "data")]);
642 cache
643 .put(&format!("key{}", i), &source, &format!("entry {}", i))
644 .unwrap();
645 std::thread::sleep(std::time::Duration::from_millis(10));
646 }
647
648 assert_eq!(cache.entry_count().unwrap(), 5);
649
650 let evicted = cache.prune(2, u64::MAX).unwrap();
652 assert_eq!(evicted, 3);
653 assert_eq!(cache.entry_count().unwrap(), 2);
654 }
655
656 #[test]
657 fn test_rootfs_cache_prune_by_size() {
658 let tmp = TempDir::new().unwrap();
659 let cache = RootfsCache::new(tmp.path()).unwrap();
660
661 for i in 0..3 {
662 let source = tmp.path().join(format!("s{}", i));
663 create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
664 cache
665 .put(&format!("key{}", i), &source, &format!("entry {}", i))
666 .unwrap();
667 std::thread::sleep(std::time::Duration::from_millis(10));
668 }
669
670 let evicted = cache.prune(usize::MAX, 1).unwrap();
672 assert!(evicted >= 2);
673 }
674
675 #[test]
676 fn test_rootfs_cache_prune_no_eviction_needed() {
677 let tmp = TempDir::new().unwrap();
678 let cache = RootfsCache::new(tmp.path()).unwrap();
679
680 let source = tmp.path().join("source");
681 create_test_rootfs(&source, &[("f.txt", "data")]);
682 cache.put("key1", &source, "entry").unwrap();
683
684 let evicted = cache.prune(10, u64::MAX).unwrap();
685 assert_eq!(evicted, 0);
686 assert_eq!(cache.entry_count().unwrap(), 1);
687 }
688
689 #[test]
690 fn prune_protecting_never_evicts_in_use_key() {
691 let tmp = TempDir::new().unwrap();
692 let cache = RootfsCache::new(tmp.path()).unwrap();
693 for i in 0..4 {
694 let src = tmp.path().join(format!("s{i}"));
695 create_test_rootfs(&src, &[("f", "x")]);
696 cache.put(&format!("k{i}"), &src, &format!("e{i}")).unwrap();
697 std::thread::sleep(std::time::Duration::from_millis(10));
698 }
699 let mut protected = std::collections::HashSet::new();
701 protected.insert("k0".to_string());
702 let evicted = cache.prune_protecting(2, u64::MAX, &protected).unwrap();
706 assert_eq!(evicted, 2, "two unprotected entries evicted to meet keep=2");
707 assert!(
708 cache.get("k0").unwrap().is_some(),
709 "the in-use (protected) lower must survive prune"
710 );
711 assert_eq!(
712 cache.entry_count().unwrap(),
713 2,
714 "k0 + one unprotected remain"
715 );
716 }
717
718 #[test]
719 fn prune_protecting_keeps_all_when_all_in_use() {
720 let tmp = TempDir::new().unwrap();
721 let cache = RootfsCache::new(tmp.path()).unwrap();
722 for i in 0..2 {
723 let src = tmp.path().join(format!("s{i}"));
724 create_test_rootfs(&src, &[("f", "x")]);
725 cache.put(&format!("k{i}"), &src, "e").unwrap();
726 }
727 let protected: std::collections::HashSet<String> =
728 ["k0", "k1"].iter().map(|s| s.to_string()).collect();
729 let evicted = cache.prune_protecting(0, 0, &protected).unwrap();
731 assert_eq!(evicted, 0, "all in-use -> nothing evicted");
732 assert_eq!(cache.entry_count().unwrap(), 2);
733 }
734
735 #[test]
736 fn prune_all_protecting_removes_complete_and_orphaned_entries() {
737 let tmp = TempDir::new().unwrap();
738 let cache = RootfsCache::new(tmp.path()).unwrap();
739 for key in ["protected", "unused"] {
740 let source = tmp.path().join(format!("source-{key}"));
741 create_test_rootfs(&source, &[("file", key)]);
742 cache.put(key, &source, key).unwrap();
743 std::fs::remove_dir_all(source).unwrap();
744 }
745 std::fs::create_dir_all(tmp.path().join("orphan-dir")).unwrap();
746 std::fs::write(tmp.path().join("orphan-meta.meta.json"), "broken").unwrap();
747 std::fs::create_dir_all(tmp.path().join(".staging-active")).unwrap();
748 std::fs::write(tmp.path().join("unused.meta.json.lock"), "").unwrap();
749
750 let protected = ["protected".to_string()].into_iter().collect();
751 let result = cache.prune_all_protecting(&protected).unwrap();
752
753 assert_eq!(result.entries_removed, 3);
754 assert!(result.bytes_freed > 0);
755 assert!(cache.get("protected").unwrap().is_some());
756 assert!(cache.get("unused").unwrap().is_none());
757 assert!(!tmp.path().join("orphan-dir").exists());
758 assert!(!tmp.path().join("orphan-meta.meta.json").exists());
759 assert!(tmp.path().join(".staging-active").exists());
760 assert!(tmp.path().join("unused.meta.json.lock").exists());
761 }
762
763 #[test]
764 fn apfs_prune_all_preserves_live_and_publication_entries() {
765 let tmp = TempDir::new().unwrap();
766 std::fs::write(tmp.path().join("protected.sparseimage"), b"live").unwrap();
767 std::fs::write(tmp.path().join("unused.sparseimage"), b"unused").unwrap();
768 std::fs::write(tmp.path().join(".unused.tmp-42"), b"publishing").unwrap();
769 std::fs::write(tmp.path().join("unrelated"), b"keep").unwrap();
770
771 let protected = ["protected".to_string()].into_iter().collect();
772 let result = prune_apfs_rootfs_cache_all(tmp.path(), &protected).unwrap();
773
774 assert_eq!(result.entries_removed, 1);
775 assert_eq!(result.bytes_freed, 6);
776 assert!(tmp.path().join("protected.sparseimage").exists());
777 assert!(!tmp.path().join("unused.sparseimage").exists());
778 assert!(tmp.path().join(".unused.tmp-42").exists());
779 assert!(tmp.path().join("unrelated").exists());
780 }
781
782 #[test]
783 fn test_rootfs_cache_metadata_persists() {
784 let tmp = TempDir::new().unwrap();
785 let cache = RootfsCache::new(tmp.path()).unwrap();
786 let key = "meta_test";
787
788 let source = tmp.path().join("source");
789 create_test_rootfs(&source, &[("file.txt", "content")]);
790 cache.put(key, &source, "test description").unwrap();
791
792 let meta_path = tmp.path().join(format!("{}.meta.json", key));
793 assert!(meta_path.is_file());
794
795 let content = std::fs::read_to_string(&meta_path).unwrap();
796 let meta: RootfsMeta = serde_json::from_str(&content).unwrap();
797
798 assert_eq!(meta.key, key);
799 assert_eq!(meta.description, "test description");
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_compute_key_deterministic() {
807 let key1 = RootfsCache::compute_key(
808 "nginx:latest",
809 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
810 &["/bin/nginx".to_string()],
811 &[("PATH".to_string(), "/usr/bin".to_string())],
812 );
813 let key2 = RootfsCache::compute_key(
814 "nginx:latest",
815 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
816 &["/bin/nginx".to_string()],
817 &[("PATH".to_string(), "/usr/bin".to_string())],
818 );
819 assert_eq!(key1, key2);
820 }
821
822 #[test]
823 fn test_compute_key_different_inputs() {
824 let key1 = RootfsCache::compute_key("nginx:latest", &[], &[], &[]);
825 let key2 = RootfsCache::compute_key("nginx:1.25", &[], &[], &[]);
826 assert_ne!(key1, key2);
827 }
828
829 #[test]
830 fn test_image_key_changes_when_a_mutable_tag_resolves_to_new_content() {
831 let first = RootfsCache::compute_image_key("example/app:latest", "sha256:first-manifest");
832 let second = RootfsCache::compute_image_key("example/app:latest", "sha256:second-manifest");
833
834 assert_ne!(first, second);
835 }
836
837 #[test]
838 fn test_compute_key_env_order_independent() {
839 let key1 = RootfsCache::compute_key(
840 "img",
841 &[],
842 &[],
843 &[
844 ("A".to_string(), "1".to_string()),
845 ("B".to_string(), "2".to_string()),
846 ],
847 );
848 let key2 = RootfsCache::compute_key(
849 "img",
850 &[],
851 &[],
852 &[
853 ("B".to_string(), "2".to_string()),
854 ("A".to_string(), "1".to_string()),
855 ],
856 );
857 assert_eq!(key1, key2);
858 }
859
860 #[test]
861 fn test_compute_key_is_hex_sha256() {
862 let key = RootfsCache::compute_key("test", &[], &[], &[]);
863 assert_eq!(key.len(), 64);
865 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
866 }
867
868 #[test]
869 fn test_compute_key_layer_order_matters() {
870 let key1 = RootfsCache::compute_key(
871 "img",
872 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
873 &[],
874 &[],
875 );
876 let key2 = RootfsCache::compute_key(
877 "img",
878 &["sha256:bbb".to_string(), "sha256:aaa".to_string()],
879 &[],
880 &[],
881 );
882 assert_ne!(key1, key2);
884 }
885
886 #[test]
887 fn test_compute_key_entrypoint_order_matters() {
888 let key1 =
889 RootfsCache::compute_key("img", &[], &["/bin/sh".to_string(), "-c".to_string()], &[]);
890 let key2 =
891 RootfsCache::compute_key("img", &[], &["-c".to_string(), "/bin/sh".to_string()], &[]);
892 assert_ne!(key1, key2);
893 }
894
895 #[test]
896 fn test_compute_key_with_special_characters() {
897 let key = RootfsCache::compute_key(
898 "registry.example.com/org/image:v1.0-beta+build.123",
899 &["sha256:abc/def".to_string()],
900 &[
901 "/bin/sh".to_string(),
902 "-c".to_string(),
903 "echo 'hello world'".to_string(),
904 ],
905 &[("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())],
906 );
907 assert_eq!(key.len(), 64);
908 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
909 }
910
911 #[test]
912 fn test_compute_key_empty_all_params() {
913 let key = RootfsCache::compute_key("", &[], &[], &[]);
914 assert_eq!(key.len(), 64);
915 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
916 }
917
918 #[test]
919 fn test_rootfs_cache_get_updates_last_accessed() {
920 let tmp = TempDir::new().unwrap();
921 let cache = RootfsCache::new(tmp.path()).unwrap();
922 let key = "access_test";
923
924 let source = tmp.path().join("source");
925 create_test_rootfs(&source, &[("f.txt", "data")]);
926 cache.put(key, &source, "test").unwrap();
927
928 let meta_path = tmp.path().join(format!("{}.meta.json", key));
930 let content = std::fs::read_to_string(&meta_path).unwrap();
931 let meta_before: RootfsMeta = serde_json::from_str(&content).unwrap();
932
933 std::thread::sleep(std::time::Duration::from_millis(10));
934
935 cache.get(key).unwrap();
937
938 let content = std::fs::read_to_string(&meta_path).unwrap();
940 let meta_after: RootfsMeta = serde_json::from_str(&content).unwrap();
941
942 assert!(meta_after.last_accessed >= meta_before.last_accessed);
943 assert_eq!(meta_after.cached_at, meta_before.cached_at);
944 }
945
946 #[test]
947 fn test_rootfs_cache_get_directory_without_metadata() {
948 let tmp = TempDir::new().unwrap();
949 let cache = RootfsCache::new(tmp.path()).unwrap();
950 let key = "no_meta";
951
952 std::fs::create_dir_all(tmp.path().join(key)).unwrap();
954
955 let result = cache.get(key).unwrap();
956 assert!(result.is_none());
957 }
958
959 #[test]
960 fn test_rootfs_cache_get_metadata_without_directory() {
961 let tmp = TempDir::new().unwrap();
962 let cache = RootfsCache::new(tmp.path()).unwrap();
963 let key = "no_dir";
964
965 let meta = RootfsMeta {
967 key: key.to_string(),
968 description: "orphan".to_string(),
969 size_bytes: 0,
970 cached_at: 0,
971 last_accessed: 0,
972 };
973 std::fs::write(
974 tmp.path().join(format!("{}.meta.json", key)),
975 serde_json::to_string(&meta).unwrap(),
976 )
977 .unwrap();
978
979 let result = cache.get(key).unwrap();
980 assert!(result.is_none());
981 }
982
983 #[test]
984 fn test_rootfs_cache_get_corrupted_metadata() {
985 let tmp = TempDir::new().unwrap();
986 let cache = RootfsCache::new(tmp.path()).unwrap();
987 let key = "corrupted";
988
989 std::fs::create_dir_all(tmp.path().join(key)).unwrap();
991 std::fs::write(
992 tmp.path().join(format!("{}.meta.json", key)),
993 "not valid json!!!",
994 )
995 .unwrap();
996
997 let result = cache.get(key).unwrap();
999 assert!(result.is_some());
1000 }
1001
1002 #[test]
1003 fn test_rootfs_cache_put_source_not_exists() {
1004 let tmp = TempDir::new().unwrap();
1005 let cache = RootfsCache::new(tmp.path()).unwrap();
1006
1007 let nonexistent = tmp.path().join("does_not_exist");
1008 let result = cache.put("bad_key", &nonexistent, "bad source");
1009 assert!(result.is_err());
1010 }
1011
1012 #[test]
1013 fn test_rootfs_cache_put_same_key_is_idempotent() {
1014 let tmp = TempDir::new().unwrap();
1019 let cache = RootfsCache::new(tmp.path()).unwrap();
1020 let key = "idempotent";
1021
1022 let s1 = tmp.path().join("v1");
1023 create_test_rootfs(&s1, &[("v1.txt", "version 1")]);
1024 let first = cache.put(key, &s1, "first").unwrap();
1025
1026 let s2 = tmp.path().join("v2");
1027 create_test_rootfs(&s2, &[("v2.txt", "version 2")]);
1028 let second = cache.put(key, &s2, "second").unwrap();
1029
1030 assert_eq!(first, second);
1032 assert!(second.join("v1.txt").is_file());
1033 assert!(!second.join("v2.txt").exists());
1034 let meta_path = tmp.path().join(format!("{}.meta.json", key));
1035 let meta: RootfsMeta =
1036 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
1037 assert_eq!(meta.description, "first");
1038 }
1039
1040 #[test]
1041 fn test_rootfs_cache_concurrent_put_same_key_no_corruption() {
1042 use std::sync::Arc;
1043
1044 let tmp = TempDir::new().unwrap();
1045 let cache = Arc::new(RootfsCache::new(tmp.path()).unwrap());
1046 let key = "concurrent";
1047 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
1048
1049 let handles: Vec<_> = (0..12)
1050 .map(|i| {
1051 let cache = Arc::clone(&cache);
1052 let src = tmp.path().join(format!("src{i}"));
1053 create_test_rootfs(&src, files);
1054 std::thread::spawn(move || cache.put(key, &src, "race").unwrap())
1055 })
1056 .collect();
1057 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1058
1059 for p in &paths {
1060 assert_eq!(p, &paths[0]);
1061 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
1062 assert_eq!(
1063 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
1064 "beta"
1065 );
1066 }
1067 assert!(cache.get(key).unwrap().is_some());
1068 }
1069
1070 #[test]
1071 fn test_rootfs_cache_prune_both_constraints() {
1072 let tmp = TempDir::new().unwrap();
1073 let cache = RootfsCache::new(tmp.path()).unwrap();
1074
1075 for i in 0..5 {
1077 let source = tmp.path().join(format!("s{}", i));
1078 create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
1079 cache
1080 .put(&format!("key{}", i), &source, &format!("entry {}", i))
1081 .unwrap();
1082 std::thread::sleep(std::time::Duration::from_millis(10));
1083 }
1084
1085 let evicted = cache.prune(3, 200).unwrap();
1088 assert!(evicted >= 2);
1089 let remaining = cache.entry_count().unwrap();
1090 assert!(remaining <= 3);
1091 }
1092
1093 #[test]
1094 fn test_rootfs_cache_prune_zero_limits() {
1095 let tmp = TempDir::new().unwrap();
1096 let cache = RootfsCache::new(tmp.path()).unwrap();
1097
1098 let source = tmp.path().join("source");
1099 create_test_rootfs(&source, &[("f.txt", "data")]);
1100 cache.put("k1", &source, "one").unwrap();
1101 cache.put("k2", &source, "two").unwrap();
1102
1103 let evicted = cache.prune(0, u64::MAX).unwrap();
1105 assert_eq!(evicted, 2);
1106 assert_eq!(cache.entry_count().unwrap(), 0);
1107 }
1108
1109 #[test]
1110 fn test_rootfs_cache_list_entries_ignores_non_meta_files() {
1111 let tmp = TempDir::new().unwrap();
1112 let cache = RootfsCache::new(tmp.path()).unwrap();
1113
1114 let source = tmp.path().join("source");
1116 create_test_rootfs(&source, &[("f.txt", "data")]);
1117 cache.put("valid_key", &source, "valid").unwrap();
1118
1119 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
1121 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
1122 std::fs::create_dir_all(tmp.path().join("random_dir")).unwrap();
1123
1124 let entries = cache.list_entries().unwrap();
1125 assert_eq!(entries.len(), 1);
1126 assert_eq!(entries[0].key, "valid_key");
1127 }
1128
1129 #[test]
1130 fn test_rootfs_cache_list_entries_skips_invalid_json() {
1131 let tmp = TempDir::new().unwrap();
1132 let cache = RootfsCache::new(tmp.path()).unwrap();
1133
1134 let source = tmp.path().join("source");
1136 create_test_rootfs(&source, &[("f.txt", "data")]);
1137 cache.put("valid_key", &source, "valid").unwrap();
1138
1139 std::fs::write(tmp.path().join("corrupted.meta.json"), "not json").unwrap();
1141
1142 let entries = cache.list_entries().unwrap();
1143 assert_eq!(entries.len(), 1);
1144 assert_eq!(entries[0].key, "valid_key");
1145 }
1146
1147 #[test]
1148 fn test_rootfs_cache_put_preserves_content() {
1149 let tmp = TempDir::new().unwrap();
1150 let cache = RootfsCache::new(tmp.path()).unwrap();
1151
1152 let source = tmp.path().join("source");
1153 create_test_rootfs(
1154 &source,
1155 &[
1156 ("bin/agent", "binary_content"),
1157 ("etc/config.json", r#"{"key":"value"}"#),
1158 ("lib/deep/nested.so", "shared_object"),
1159 ],
1160 );
1161
1162 let cached = cache.put("content_key", &source, "content test").unwrap();
1163
1164 assert_eq!(
1165 std::fs::read_to_string(cached.join("bin/agent")).unwrap(),
1166 "binary_content"
1167 );
1168 assert_eq!(
1169 std::fs::read_to_string(cached.join("etc/config.json")).unwrap(),
1170 r#"{"key":"value"}"#
1171 );
1172 assert_eq!(
1173 std::fs::read_to_string(cached.join("lib/deep/nested.so")).unwrap(),
1174 "shared_object"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_rootfs_cache_invalidate_then_put_same_key() {
1180 let tmp = TempDir::new().unwrap();
1181 let cache = RootfsCache::new(tmp.path()).unwrap();
1182 let key = "reuse_key";
1183
1184 let s1 = tmp.path().join("s1");
1185 create_test_rootfs(&s1, &[("v1.txt", "first")]);
1186 cache.put(key, &s1, "first").unwrap();
1187
1188 cache.invalidate(key).unwrap();
1189 assert!(cache.get(key).unwrap().is_none());
1190
1191 let s2 = tmp.path().join("s2");
1192 create_test_rootfs(&s2, &[("v2.txt", "second")]);
1193 let cached = cache.put(key, &s2, "second").unwrap();
1194
1195 assert!(cache.get(key).unwrap().is_some());
1196 assert!(cached.join("v2.txt").is_file());
1197 assert!(!cached.join("v1.txt").exists());
1198 }
1199}