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