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