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, Clone, Serialize, Deserialize)]
15pub struct RootfsMeta {
16 pub key: String,
18 pub description: String,
20 pub size_bytes: u64,
22 pub cached_at: i64,
24 pub last_accessed: i64,
26}
27
28pub struct RootfsCache {
33 cache_dir: PathBuf,
35}
36
37impl RootfsCache {
38 pub fn new(cache_dir: &Path) -> Result<Self> {
40 std::fs::create_dir_all(cache_dir).map_err(|e| {
41 BoxError::CacheError(format!(
42 "Failed to create rootfs cache directory {}: {}",
43 cache_dir.display(),
44 e
45 ))
46 })?;
47
48 Ok(Self {
49 cache_dir: cache_dir.to_path_buf(),
50 })
51 }
52
53 pub fn compute_key(
61 image_ref: &str,
62 layer_digests: &[String],
63 entrypoint: &[String],
64 env: &[(String, String)],
65 ) -> String {
66 let mut hasher = Sha256::new();
67 hasher.update(b"rootfs-cache-v2\n");
71 hasher.update(image_ref.as_bytes());
72 hasher.update(b"\n");
73
74 for digest in layer_digests {
75 hasher.update(digest.as_bytes());
76 hasher.update(b"\n");
77 }
78
79 for part in entrypoint {
80 hasher.update(part.as_bytes());
81 hasher.update(b"\n");
82 }
83
84 let mut sorted_env: Vec<_> = env.to_vec();
85 sorted_env.sort();
86 for (k, v) in &sorted_env {
87 hasher.update(k.as_bytes());
88 hasher.update(b"=");
89 hasher.update(v.as_bytes());
90 hasher.update(b"\n");
91 }
92
93 hex::encode(hasher.finalize())
94 }
95
96 pub fn get(&self, key: &str) -> Result<Option<PathBuf>> {
100 let rootfs_dir = self.cache_dir.join(key);
101 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
102
103 if !rootfs_dir.is_dir() || !meta_path.is_file() {
104 return Ok(None);
105 }
106
107 if let Ok(content) = std::fs::read_to_string(&meta_path) {
109 if let Ok(mut meta) = serde_json::from_str::<RootfsMeta>(&content) {
110 meta.last_accessed = chrono::Utc::now().timestamp();
111 if let Err(e) = super::layer_cache::write_meta_atomically(
112 &meta_path,
113 &serde_json::to_string_pretty(&meta)?,
114 ) {
115 tracing::warn!(path = %meta_path.display(), error = %e, "Failed to update rootfs cache metadata");
116 }
117 }
118 }
119
120 Ok(Some(rootfs_dir))
121 }
122
123 pub fn put(&self, key: &str, source_rootfs: &Path, description: &str) -> Result<PathBuf> {
128 let rootfs_dir = self.cache_dir.join(key);
129 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
130
131 if rootfs_dir.is_dir() && meta_path.is_file() {
136 return Ok(rootfs_dir);
137 }
138
139 super::layer_cache::publish_dir_atomically(source_rootfs, &rootfs_dir, &self.cache_dir)?;
143
144 let size_bytes = super::layer_cache::dir_size(&rootfs_dir).unwrap_or(0);
146
147 let now = chrono::Utc::now().timestamp();
149 let meta = RootfsMeta {
150 key: key.to_string(),
151 description: description.to_string(),
152 size_bytes,
153 cached_at: now,
154 last_accessed: now,
155 };
156 super::layer_cache::write_meta_atomically(
157 &meta_path,
158 &serde_json::to_string_pretty(&meta)?,
159 )?;
160
161 tracing::debug!(
162 key = %key,
163 description = %description,
164 size_bytes,
165 path = %rootfs_dir.display(),
166 "Cached rootfs"
167 );
168
169 Ok(rootfs_dir)
170 }
171
172 pub fn invalidate(&self, key: &str) -> Result<()> {
174 let rootfs_dir = self.cache_dir.join(key);
175 let meta_path = self.cache_dir.join(format!("{}.meta.json", key));
176
177 if rootfs_dir.exists() {
178 std::fs::remove_dir_all(&rootfs_dir).map_err(|e| {
179 BoxError::CacheError(format!(
180 "Failed to remove cached rootfs {}: {}",
181 rootfs_dir.display(),
182 e
183 ))
184 })?;
185 }
186 if meta_path.exists() {
187 std::fs::remove_file(&meta_path).map_err(|e| {
188 BoxError::CacheError(format!(
189 "Failed to remove rootfs metadata {}: {}",
190 meta_path.display(),
191 e
192 ))
193 })?;
194 }
195
196 Ok(())
197 }
198
199 pub fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
203 self.prune_protecting(max_entries, max_bytes, &std::collections::HashSet::new())
204 }
205
206 pub fn prune_protecting(
215 &self,
216 max_entries: usize,
217 max_bytes: u64,
218 protected: &std::collections::HashSet<String>,
219 ) -> Result<usize> {
220 let mut entries = self.list_entries()?;
221
222 if entries.len() <= max_entries {
223 let total_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
224 if total_size <= max_bytes {
225 return Ok(0);
226 }
227 }
228
229 entries.sort_by_key(|e| e.last_accessed);
231
232 let mut current_count = entries.len();
233 let mut current_size: u64 = entries.iter().map(|e| e.size_bytes).sum();
234 let mut evicted = 0;
235
236 for entry in &entries {
237 if current_count <= max_entries && current_size <= max_bytes {
238 break;
239 }
240 if protected.contains(&entry.key) {
243 continue;
244 }
245 self.invalidate(&entry.key)?;
246 current_count -= 1;
247 current_size = current_size.saturating_sub(entry.size_bytes);
248 evicted += 1;
249
250 tracing::debug!(
251 key = %entry.key,
252 description = %entry.description,
253 size_bytes = entry.size_bytes,
254 "Evicted cached rootfs"
255 );
256 }
257
258 Ok(evicted)
259 }
260
261 pub fn list_entries(&self) -> Result<Vec<RootfsMeta>> {
263 let mut entries = Vec::new();
264
265 let read_dir = std::fs::read_dir(&self.cache_dir).map_err(|e| {
266 BoxError::CacheError(format!(
267 "Failed to read rootfs cache directory {}: {}",
268 self.cache_dir.display(),
269 e
270 ))
271 })?;
272
273 for entry in read_dir {
274 let entry = entry.map_err(|e| {
275 BoxError::CacheError(format!("Failed to read directory entry: {}", e))
276 })?;
277 let path = entry.path();
278
279 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
280 if name.ends_with(".meta.json") {
281 if let Ok(content) = std::fs::read_to_string(&path) {
282 if let Ok(meta) = serde_json::from_str::<RootfsMeta>(&content) {
283 entries.push(meta);
284 }
285 }
286 }
287 }
288 }
289
290 Ok(entries)
291 }
292
293 pub fn total_size(&self) -> Result<u64> {
295 Ok(self.list_entries()?.iter().map(|e| e.size_bytes).sum())
296 }
297
298 pub fn entry_count(&self) -> Result<usize> {
300 Ok(self.list_entries()?.len())
301 }
302}
303
304impl a3s_box_core::traits::CacheBackend for RootfsCache {
305 fn get(&self, key: &str) -> Result<Option<PathBuf>> {
306 self.get(key)
307 }
308
309 fn put(&self, key: &str, source_dir: &Path, description: &str) -> Result<PathBuf> {
310 self.put(key, source_dir, description)
311 }
312
313 fn invalidate(&self, key: &str) -> Result<()> {
314 self.invalidate(key)
315 }
316
317 fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
318 self.prune(max_entries, max_bytes)
319 }
320
321 fn list(&self) -> Result<Vec<a3s_box_core::traits::CacheEntry>> {
322 self.list_entries().map(|entries| {
323 entries
324 .into_iter()
325 .map(|m| a3s_box_core::traits::CacheEntry {
326 key: m.key,
327 description: m.description,
328 size_bytes: m.size_bytes,
329 cached_at: m.cached_at,
330 last_accessed: m.last_accessed,
331 })
332 .collect()
333 })
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use tempfile::TempDir;
341
342 fn create_test_rootfs(dir: &Path, files: &[(&str, &str)]) {
343 std::fs::create_dir_all(dir).unwrap();
344 for (name, content) in files {
345 let file_path = dir.join(name);
346 if let Some(parent) = file_path.parent() {
347 std::fs::create_dir_all(parent).unwrap();
348 }
349 std::fs::write(&file_path, content).unwrap();
350 }
351 }
352
353 #[test]
354 fn test_rootfs_cache_new_creates_directory() {
355 let tmp = TempDir::new().unwrap();
356 let cache_dir = tmp.path().join("rootfs");
357
358 assert!(!cache_dir.exists());
359 let _cache = RootfsCache::new(&cache_dir).unwrap();
360 assert!(cache_dir.is_dir());
361 }
362
363 #[test]
364 fn test_rootfs_cache_get_miss() {
365 let tmp = TempDir::new().unwrap();
366 let cache = RootfsCache::new(tmp.path()).unwrap();
367
368 let result = cache.get("nonexistent_key").unwrap();
369 assert!(result.is_none());
370 }
371
372 #[test]
373 fn test_rootfs_cache_put_and_get() {
374 let tmp = TempDir::new().unwrap();
375 let cache = RootfsCache::new(tmp.path()).unwrap();
376
377 let source = tmp.path().join("source_rootfs");
378 create_test_rootfs(
379 &source,
380 &[("bin/agent", "binary"), ("etc/config.json", "{}")],
381 );
382
383 let key = "abc123def456";
384 let cached_path = cache.put(key, &source, "test rootfs").unwrap();
385
386 assert!(cached_path.is_dir());
387 assert!(cached_path.join("bin/agent").is_file());
388 assert!(cached_path.join("etc/config.json").is_file());
389
390 let result = cache.get(key).unwrap();
391 assert!(result.is_some());
392 assert_eq!(result.unwrap(), cached_path);
393 }
394
395 #[test]
396 fn test_rootfs_cache_invalidate() {
397 let tmp = TempDir::new().unwrap();
398 let cache = RootfsCache::new(tmp.path()).unwrap();
399 let key = "to_invalidate";
400
401 let source = tmp.path().join("source");
402 create_test_rootfs(&source, &[("data.bin", "data")]);
403 cache.put(key, &source, "temp").unwrap();
404
405 assert!(cache.get(key).unwrap().is_some());
406 cache.invalidate(key).unwrap();
407 assert!(cache.get(key).unwrap().is_none());
408 }
409
410 #[test]
411 fn test_rootfs_cache_invalidate_nonexistent() {
412 let tmp = TempDir::new().unwrap();
413 let cache = RootfsCache::new(tmp.path()).unwrap();
414 cache.invalidate("does_not_exist").unwrap();
415 }
416
417 #[test]
418 fn test_rootfs_cache_list_entries() {
419 let tmp = TempDir::new().unwrap();
420 let cache = RootfsCache::new(tmp.path()).unwrap();
421
422 assert_eq!(cache.list_entries().unwrap().len(), 0);
423
424 let s1 = tmp.path().join("s1");
425 create_test_rootfs(&s1, &[("a.txt", "aaa")]);
426 cache.put("key1", &s1, "first").unwrap();
427
428 let s2 = tmp.path().join("s2");
429 create_test_rootfs(&s2, &[("b.txt", "bbb")]);
430 cache.put("key2", &s2, "second").unwrap();
431
432 let entries = cache.list_entries().unwrap();
433 assert_eq!(entries.len(), 2);
434
435 let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect();
436 assert!(keys.contains(&"key1"));
437 assert!(keys.contains(&"key2"));
438 }
439
440 #[test]
441 fn test_rootfs_cache_entry_count() {
442 let tmp = TempDir::new().unwrap();
443 let cache = RootfsCache::new(tmp.path()).unwrap();
444
445 assert_eq!(cache.entry_count().unwrap(), 0);
446
447 let source = tmp.path().join("source");
448 create_test_rootfs(&source, &[("f.txt", "data")]);
449 cache.put("k1", &source, "one").unwrap();
450 cache.put("k2", &source, "two").unwrap();
451
452 assert_eq!(cache.entry_count().unwrap(), 2);
453 }
454
455 #[test]
456 fn test_rootfs_cache_total_size() {
457 let tmp = TempDir::new().unwrap();
458 let cache = RootfsCache::new(tmp.path()).unwrap();
459
460 assert_eq!(cache.total_size().unwrap(), 0);
461
462 let source = tmp.path().join("source");
463 create_test_rootfs(&source, &[("data.txt", "hello world")]);
464 cache.put("sized", &source, "sized entry").unwrap();
465
466 assert!(cache.total_size().unwrap() > 0);
467 }
468
469 #[test]
470 fn test_rootfs_cache_prune_by_count() {
471 let tmp = TempDir::new().unwrap();
472 let cache = RootfsCache::new(tmp.path()).unwrap();
473
474 for i in 0..5 {
476 let source = tmp.path().join(format!("s{}", i));
477 create_test_rootfs(&source, &[("f.txt", "data")]);
478 cache
479 .put(&format!("key{}", i), &source, &format!("entry {}", i))
480 .unwrap();
481 std::thread::sleep(std::time::Duration::from_millis(10));
482 }
483
484 assert_eq!(cache.entry_count().unwrap(), 5);
485
486 let evicted = cache.prune(2, u64::MAX).unwrap();
488 assert_eq!(evicted, 3);
489 assert_eq!(cache.entry_count().unwrap(), 2);
490 }
491
492 #[test]
493 fn test_rootfs_cache_prune_by_size() {
494 let tmp = TempDir::new().unwrap();
495 let cache = RootfsCache::new(tmp.path()).unwrap();
496
497 for i in 0..3 {
498 let source = tmp.path().join(format!("s{}", i));
499 create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
500 cache
501 .put(&format!("key{}", i), &source, &format!("entry {}", i))
502 .unwrap();
503 std::thread::sleep(std::time::Duration::from_millis(10));
504 }
505
506 let evicted = cache.prune(usize::MAX, 1).unwrap();
508 assert!(evicted >= 2);
509 }
510
511 #[test]
512 fn test_rootfs_cache_prune_no_eviction_needed() {
513 let tmp = TempDir::new().unwrap();
514 let cache = RootfsCache::new(tmp.path()).unwrap();
515
516 let source = tmp.path().join("source");
517 create_test_rootfs(&source, &[("f.txt", "data")]);
518 cache.put("key1", &source, "entry").unwrap();
519
520 let evicted = cache.prune(10, u64::MAX).unwrap();
521 assert_eq!(evicted, 0);
522 assert_eq!(cache.entry_count().unwrap(), 1);
523 }
524
525 #[test]
526 fn prune_protecting_never_evicts_in_use_key() {
527 let tmp = TempDir::new().unwrap();
528 let cache = RootfsCache::new(tmp.path()).unwrap();
529 for i in 0..4 {
530 let src = tmp.path().join(format!("s{i}"));
531 create_test_rootfs(&src, &[("f", "x")]);
532 cache.put(&format!("k{i}"), &src, &format!("e{i}")).unwrap();
533 std::thread::sleep(std::time::Duration::from_millis(10));
534 }
535 let mut protected = std::collections::HashSet::new();
537 protected.insert("k0".to_string());
538 let evicted = cache.prune_protecting(2, u64::MAX, &protected).unwrap();
542 assert_eq!(evicted, 2, "two unprotected entries evicted to meet keep=2");
543 assert!(
544 cache.get("k0").unwrap().is_some(),
545 "the in-use (protected) lower must survive prune"
546 );
547 assert_eq!(
548 cache.entry_count().unwrap(),
549 2,
550 "k0 + one unprotected remain"
551 );
552 }
553
554 #[test]
555 fn prune_protecting_keeps_all_when_all_in_use() {
556 let tmp = TempDir::new().unwrap();
557 let cache = RootfsCache::new(tmp.path()).unwrap();
558 for i in 0..2 {
559 let src = tmp.path().join(format!("s{i}"));
560 create_test_rootfs(&src, &[("f", "x")]);
561 cache.put(&format!("k{i}"), &src, "e").unwrap();
562 }
563 let protected: std::collections::HashSet<String> =
564 ["k0", "k1"].iter().map(|s| s.to_string()).collect();
565 let evicted = cache.prune_protecting(0, 0, &protected).unwrap();
567 assert_eq!(evicted, 0, "all in-use -> nothing evicted");
568 assert_eq!(cache.entry_count().unwrap(), 2);
569 }
570
571 #[test]
572 fn test_rootfs_cache_metadata_persists() {
573 let tmp = TempDir::new().unwrap();
574 let cache = RootfsCache::new(tmp.path()).unwrap();
575 let key = "meta_test";
576
577 let source = tmp.path().join("source");
578 create_test_rootfs(&source, &[("file.txt", "content")]);
579 cache.put(key, &source, "test description").unwrap();
580
581 let meta_path = tmp.path().join(format!("{}.meta.json", key));
582 assert!(meta_path.is_file());
583
584 let content = std::fs::read_to_string(&meta_path).unwrap();
585 let meta: RootfsMeta = serde_json::from_str(&content).unwrap();
586
587 assert_eq!(meta.key, key);
588 assert_eq!(meta.description, "test description");
589 assert!(meta.size_bytes > 0);
590 assert!(meta.cached_at > 0);
591 assert_eq!(meta.cached_at, meta.last_accessed);
592 }
593
594 #[test]
595 fn test_compute_key_deterministic() {
596 let key1 = RootfsCache::compute_key(
597 "nginx:latest",
598 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
599 &["/bin/nginx".to_string()],
600 &[("PATH".to_string(), "/usr/bin".to_string())],
601 );
602 let key2 = RootfsCache::compute_key(
603 "nginx:latest",
604 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
605 &["/bin/nginx".to_string()],
606 &[("PATH".to_string(), "/usr/bin".to_string())],
607 );
608 assert_eq!(key1, key2);
609 }
610
611 #[test]
612 fn test_compute_key_different_inputs() {
613 let key1 = RootfsCache::compute_key("nginx:latest", &[], &[], &[]);
614 let key2 = RootfsCache::compute_key("nginx:1.25", &[], &[], &[]);
615 assert_ne!(key1, key2);
616 }
617
618 #[test]
619 fn test_compute_key_env_order_independent() {
620 let key1 = RootfsCache::compute_key(
621 "img",
622 &[],
623 &[],
624 &[
625 ("A".to_string(), "1".to_string()),
626 ("B".to_string(), "2".to_string()),
627 ],
628 );
629 let key2 = RootfsCache::compute_key(
630 "img",
631 &[],
632 &[],
633 &[
634 ("B".to_string(), "2".to_string()),
635 ("A".to_string(), "1".to_string()),
636 ],
637 );
638 assert_eq!(key1, key2);
639 }
640
641 #[test]
642 fn test_compute_key_is_hex_sha256() {
643 let key = RootfsCache::compute_key("test", &[], &[], &[]);
644 assert_eq!(key.len(), 64);
646 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
647 }
648
649 #[test]
650 fn test_compute_key_layer_order_matters() {
651 let key1 = RootfsCache::compute_key(
652 "img",
653 &["sha256:aaa".to_string(), "sha256:bbb".to_string()],
654 &[],
655 &[],
656 );
657 let key2 = RootfsCache::compute_key(
658 "img",
659 &["sha256:bbb".to_string(), "sha256:aaa".to_string()],
660 &[],
661 &[],
662 );
663 assert_ne!(key1, key2);
665 }
666
667 #[test]
668 fn test_compute_key_entrypoint_order_matters() {
669 let key1 =
670 RootfsCache::compute_key("img", &[], &["/bin/sh".to_string(), "-c".to_string()], &[]);
671 let key2 =
672 RootfsCache::compute_key("img", &[], &["-c".to_string(), "/bin/sh".to_string()], &[]);
673 assert_ne!(key1, key2);
674 }
675
676 #[test]
677 fn test_compute_key_with_special_characters() {
678 let key = RootfsCache::compute_key(
679 "registry.example.com/org/image:v1.0-beta+build.123",
680 &["sha256:abc/def".to_string()],
681 &[
682 "/bin/sh".to_string(),
683 "-c".to_string(),
684 "echo 'hello world'".to_string(),
685 ],
686 &[("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())],
687 );
688 assert_eq!(key.len(), 64);
689 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
690 }
691
692 #[test]
693 fn test_compute_key_empty_all_params() {
694 let key = RootfsCache::compute_key("", &[], &[], &[]);
695 assert_eq!(key.len(), 64);
696 assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
697 }
698
699 #[test]
700 fn test_rootfs_cache_get_updates_last_accessed() {
701 let tmp = TempDir::new().unwrap();
702 let cache = RootfsCache::new(tmp.path()).unwrap();
703 let key = "access_test";
704
705 let source = tmp.path().join("source");
706 create_test_rootfs(&source, &[("f.txt", "data")]);
707 cache.put(key, &source, "test").unwrap();
708
709 let meta_path = tmp.path().join(format!("{}.meta.json", key));
711 let content = std::fs::read_to_string(&meta_path).unwrap();
712 let meta_before: RootfsMeta = serde_json::from_str(&content).unwrap();
713
714 std::thread::sleep(std::time::Duration::from_millis(10));
715
716 cache.get(key).unwrap();
718
719 let content = std::fs::read_to_string(&meta_path).unwrap();
721 let meta_after: RootfsMeta = serde_json::from_str(&content).unwrap();
722
723 assert!(meta_after.last_accessed >= meta_before.last_accessed);
724 assert_eq!(meta_after.cached_at, meta_before.cached_at);
725 }
726
727 #[test]
728 fn test_rootfs_cache_get_directory_without_metadata() {
729 let tmp = TempDir::new().unwrap();
730 let cache = RootfsCache::new(tmp.path()).unwrap();
731 let key = "no_meta";
732
733 std::fs::create_dir_all(tmp.path().join(key)).unwrap();
735
736 let result = cache.get(key).unwrap();
737 assert!(result.is_none());
738 }
739
740 #[test]
741 fn test_rootfs_cache_get_metadata_without_directory() {
742 let tmp = TempDir::new().unwrap();
743 let cache = RootfsCache::new(tmp.path()).unwrap();
744 let key = "no_dir";
745
746 let meta = RootfsMeta {
748 key: key.to_string(),
749 description: "orphan".to_string(),
750 size_bytes: 0,
751 cached_at: 0,
752 last_accessed: 0,
753 };
754 std::fs::write(
755 tmp.path().join(format!("{}.meta.json", key)),
756 serde_json::to_string(&meta).unwrap(),
757 )
758 .unwrap();
759
760 let result = cache.get(key).unwrap();
761 assert!(result.is_none());
762 }
763
764 #[test]
765 fn test_rootfs_cache_get_corrupted_metadata() {
766 let tmp = TempDir::new().unwrap();
767 let cache = RootfsCache::new(tmp.path()).unwrap();
768 let key = "corrupted";
769
770 std::fs::create_dir_all(tmp.path().join(key)).unwrap();
772 std::fs::write(
773 tmp.path().join(format!("{}.meta.json", key)),
774 "not valid json!!!",
775 )
776 .unwrap();
777
778 let result = cache.get(key).unwrap();
780 assert!(result.is_some());
781 }
782
783 #[test]
784 fn test_rootfs_cache_put_source_not_exists() {
785 let tmp = TempDir::new().unwrap();
786 let cache = RootfsCache::new(tmp.path()).unwrap();
787
788 let nonexistent = tmp.path().join("does_not_exist");
789 let result = cache.put("bad_key", &nonexistent, "bad source");
790 assert!(result.is_err());
791 }
792
793 #[test]
794 fn test_rootfs_cache_put_same_key_is_idempotent() {
795 let tmp = TempDir::new().unwrap();
800 let cache = RootfsCache::new(tmp.path()).unwrap();
801 let key = "idempotent";
802
803 let s1 = tmp.path().join("v1");
804 create_test_rootfs(&s1, &[("v1.txt", "version 1")]);
805 let first = cache.put(key, &s1, "first").unwrap();
806
807 let s2 = tmp.path().join("v2");
808 create_test_rootfs(&s2, &[("v2.txt", "version 2")]);
809 let second = cache.put(key, &s2, "second").unwrap();
810
811 assert_eq!(first, second);
813 assert!(second.join("v1.txt").is_file());
814 assert!(!second.join("v2.txt").exists());
815 let meta_path = tmp.path().join(format!("{}.meta.json", key));
816 let meta: RootfsMeta =
817 serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
818 assert_eq!(meta.description, "first");
819 }
820
821 #[test]
822 fn test_rootfs_cache_concurrent_put_same_key_no_corruption() {
823 use std::sync::Arc;
824
825 let tmp = TempDir::new().unwrap();
826 let cache = Arc::new(RootfsCache::new(tmp.path()).unwrap());
827 let key = "concurrent";
828 let files: &[(&str, &str)] = &[("a.txt", "alpha"), ("sub/b.txt", "beta")];
829
830 let handles: Vec<_> = (0..12)
831 .map(|i| {
832 let cache = Arc::clone(&cache);
833 let src = tmp.path().join(format!("src{i}"));
834 create_test_rootfs(&src, files);
835 std::thread::spawn(move || cache.put(key, &src, "race").unwrap())
836 })
837 .collect();
838 let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
839
840 for p in &paths {
841 assert_eq!(p, &paths[0]);
842 assert_eq!(std::fs::read_to_string(p.join("a.txt")).unwrap(), "alpha");
843 assert_eq!(
844 std::fs::read_to_string(p.join("sub/b.txt")).unwrap(),
845 "beta"
846 );
847 }
848 assert!(cache.get(key).unwrap().is_some());
849 }
850
851 #[test]
852 fn test_rootfs_cache_prune_both_constraints() {
853 let tmp = TempDir::new().unwrap();
854 let cache = RootfsCache::new(tmp.path()).unwrap();
855
856 for i in 0..5 {
858 let source = tmp.path().join(format!("s{}", i));
859 create_test_rootfs(&source, &[("f.txt", &"x".repeat(100))]);
860 cache
861 .put(&format!("key{}", i), &source, &format!("entry {}", i))
862 .unwrap();
863 std::thread::sleep(std::time::Duration::from_millis(10));
864 }
865
866 let evicted = cache.prune(3, 200).unwrap();
869 assert!(evicted >= 2);
870 let remaining = cache.entry_count().unwrap();
871 assert!(remaining <= 3);
872 }
873
874 #[test]
875 fn test_rootfs_cache_prune_zero_limits() {
876 let tmp = TempDir::new().unwrap();
877 let cache = RootfsCache::new(tmp.path()).unwrap();
878
879 let source = tmp.path().join("source");
880 create_test_rootfs(&source, &[("f.txt", "data")]);
881 cache.put("k1", &source, "one").unwrap();
882 cache.put("k2", &source, "two").unwrap();
883
884 let evicted = cache.prune(0, u64::MAX).unwrap();
886 assert_eq!(evicted, 2);
887 assert_eq!(cache.entry_count().unwrap(), 0);
888 }
889
890 #[test]
891 fn test_rootfs_cache_list_entries_ignores_non_meta_files() {
892 let tmp = TempDir::new().unwrap();
893 let cache = RootfsCache::new(tmp.path()).unwrap();
894
895 let source = tmp.path().join("source");
897 create_test_rootfs(&source, &[("f.txt", "data")]);
898 cache.put("valid_key", &source, "valid").unwrap();
899
900 std::fs::write(tmp.path().join("random.txt"), "noise").unwrap();
902 std::fs::write(tmp.path().join("other.json"), "{}").unwrap();
903 std::fs::create_dir_all(tmp.path().join("random_dir")).unwrap();
904
905 let entries = cache.list_entries().unwrap();
906 assert_eq!(entries.len(), 1);
907 assert_eq!(entries[0].key, "valid_key");
908 }
909
910 #[test]
911 fn test_rootfs_cache_list_entries_skips_invalid_json() {
912 let tmp = TempDir::new().unwrap();
913 let cache = RootfsCache::new(tmp.path()).unwrap();
914
915 let source = tmp.path().join("source");
917 create_test_rootfs(&source, &[("f.txt", "data")]);
918 cache.put("valid_key", &source, "valid").unwrap();
919
920 std::fs::write(tmp.path().join("corrupted.meta.json"), "not json").unwrap();
922
923 let entries = cache.list_entries().unwrap();
924 assert_eq!(entries.len(), 1);
925 assert_eq!(entries[0].key, "valid_key");
926 }
927
928 #[test]
929 fn test_rootfs_cache_put_preserves_content() {
930 let tmp = TempDir::new().unwrap();
931 let cache = RootfsCache::new(tmp.path()).unwrap();
932
933 let source = tmp.path().join("source");
934 create_test_rootfs(
935 &source,
936 &[
937 ("bin/agent", "binary_content"),
938 ("etc/config.json", r#"{"key":"value"}"#),
939 ("lib/deep/nested.so", "shared_object"),
940 ],
941 );
942
943 let cached = cache.put("content_key", &source, "content test").unwrap();
944
945 assert_eq!(
946 std::fs::read_to_string(cached.join("bin/agent")).unwrap(),
947 "binary_content"
948 );
949 assert_eq!(
950 std::fs::read_to_string(cached.join("etc/config.json")).unwrap(),
951 r#"{"key":"value"}"#
952 );
953 assert_eq!(
954 std::fs::read_to_string(cached.join("lib/deep/nested.so")).unwrap(),
955 "shared_object"
956 );
957 }
958
959 #[test]
960 fn test_rootfs_cache_invalidate_then_put_same_key() {
961 let tmp = TempDir::new().unwrap();
962 let cache = RootfsCache::new(tmp.path()).unwrap();
963 let key = "reuse_key";
964
965 let s1 = tmp.path().join("s1");
966 create_test_rootfs(&s1, &[("v1.txt", "first")]);
967 cache.put(key, &s1, "first").unwrap();
968
969 cache.invalidate(key).unwrap();
970 assert!(cache.get(key).unwrap().is_none());
971
972 let s2 = tmp.path().join("s2");
973 create_test_rootfs(&s2, &[("v2.txt", "second")]);
974 let cached = cache.put(key, &s2, "second").unwrap();
975
976 assert!(cache.get(key).unwrap().is_some());
977 assert!(cached.join("v2.txt").is_file());
978 assert!(!cached.join("v1.txt").exists());
979 }
980}