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