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