1use std::path::{Path, PathBuf};
40use std::sync::{Arc, Mutex};
41
42use devboy_core::asset::AssetContext;
43
44use crate::cache::{CacheManager, resolve_under_root};
45use crate::config::{AssetConfig, ResolvedAssetConfig};
46use crate::error::{AssetError, Result};
47use crate::index::{AssetIndex, CachedAsset, INDEX_FILENAME, NewCachedAsset};
48use crate::rotation::{RotationStats, Rotator};
49
50#[derive(Debug, Clone)]
52pub struct AssetManager {
53 inner: Arc<Inner>,
54}
55
56#[derive(Debug)]
57struct Inner {
58 config: ResolvedAssetConfig,
59 cache: CacheManager,
60 rotator: Rotator,
61 state: Mutex<AssetIndex>,
62}
63
64impl AssetManager {
65 pub fn from_config(config: AssetConfig) -> Result<Self> {
67 let resolved = config.resolve()?;
68 Self::from_resolved(resolved)
69 }
70
71 pub fn from_resolved(config: ResolvedAssetConfig) -> Result<Self> {
84 let cache = CacheManager::new(config.cache_dir.clone())?;
85 let rotator = Rotator::new(&config);
86 let mut index = AssetIndex::load(&config.cache_dir)?;
87 let pruned = prune_stale_entries(&mut index, &cache);
88 let rotated = rotator.rotate(&mut index, &cache)?;
89 if pruned > 0 || rotated.removed() > 0 {
90 tracing::debug!(
91 pruned,
92 aged_out = rotated.aged_out,
93 size_evicted = rotated.size_evicted,
94 bytes_freed = rotated.bytes_freed,
95 "asset cache startup cleanup",
96 );
97 }
98 index.save(&config.cache_dir)?;
99
100 Ok(Self {
101 inner: Arc::new(Inner {
102 config,
103 cache,
104 rotator,
105 state: Mutex::new(index),
106 }),
107 })
108 }
109
110 pub fn with_root(root: PathBuf) -> Result<Self> {
118 use crate::config::{
119 DEFAULT_MAX_CACHE_SIZE, DEFAULT_MAX_FILE_AGE, EvictionPolicy, ResolvedAssetConfig,
120 parse_duration, parse_size,
121 };
122 let resolved = ResolvedAssetConfig {
123 cache_dir: root,
124 max_cache_size: parse_size(DEFAULT_MAX_CACHE_SIZE)
125 .expect("default cache size is valid"),
126 max_file_age: parse_duration(DEFAULT_MAX_FILE_AGE).expect("default file age is valid"),
127 eviction_policy: EvictionPolicy::Lru,
128 };
129 Self::from_resolved(resolved)
130 }
131
132 pub fn cache_dir(&self) -> &Path {
134 &self.inner.config.cache_dir
135 }
136
137 pub fn config(&self) -> &ResolvedAssetConfig {
139 &self.inner.config
140 }
141
142 pub fn store(&self, request: StoreRequest<'_>) -> Result<CachedAsset> {
155 let StoreRequest {
156 context,
157 asset_id: asset_id_opt,
158 filename,
159 mime_type,
160 remote_url,
161 data,
162 } = request;
163
164 let asset_id_owned: String;
166 let asset_id: &str = match asset_id_opt {
167 Some(id) => {
168 let trimmed = id.trim();
169 if trimmed.is_empty() {
170 return Err(AssetError::config("asset_id must not be empty"));
171 }
172 if trimmed.len() > MAX_ASSET_ID_LEN {
173 return Err(AssetError::config(format!(
174 "asset_id is {} chars, max allowed is {MAX_ASSET_ID_LEN}",
175 trimmed.len(),
176 )));
177 }
178 trimmed
179 }
180 None => {
181 asset_id_owned = Self::content_id(data);
182 &asset_id_owned
183 }
184 };
185
186 let size = data.len() as u64;
187 let max = self.inner.config.max_cache_size;
188 if max > 0 && size > max {
189 return Err(AssetError::config(format!(
190 "asset '{asset_id}' is {size} bytes, which exceeds the cache \
191 budget of {max} bytes; increase `[assets] max_cache_size` or \
192 split the file",
193 )));
194 }
195
196 let stored = self.inner.cache.store(&context, asset_id, filename, data)?;
197 let rel_path = stored
198 .path
199 .strip_prefix(self.inner.cache.root())
200 .map_err(|e| AssetError::cache_dir(format!("path outside cache root: {e}")))?
201 .to_path_buf();
202
203 let asset = CachedAsset::new(NewCachedAsset {
204 id: asset_id.to_string(),
205 filename: filename.to_string(),
206 mime_type,
207 size: stored.size,
208 local_path: rel_path,
209 context,
210 checksum_sha256: stored.checksum_sha256,
211 remote_url,
212 });
213
214 let mut deferred_delete: Option<PathBuf> = None;
219 let mut overwrote_same_path = false;
225 let result: Result<()> = (|| {
226 let mut index = self.state_lock()?;
227
228 if let Some(previous) = index.get(asset.id.as_str()) {
229 if previous.local_path == asset.local_path {
230 overwrote_same_path = true;
232 } else {
233 deferred_delete =
236 resolve_under_root(&self.inner.config.cache_dir, &previous.local_path);
237 }
238 }
239
240 let snapshot = index.clone();
242
243 index.upsert(asset.clone());
244 if let Err(e) = self
245 .inner
246 .rotator
247 .rotate(&mut index, &self.inner.cache)
248 .and_then(|_| {
249 if index.get(asset.id.as_str()).is_none() {
253 return Err(AssetError::config(format!(
254 "asset '{}' was evicted immediately after store — \
255 the cache budget ({} bytes) is too small for this file \
256 ({} bytes) alongside existing entries",
257 asset.id, self.inner.config.max_cache_size, asset.size,
258 )));
259 }
260 index.save(&self.inner.config.cache_dir)
261 })
262 {
263 *index = snapshot;
266 deferred_delete = None; return Err(e);
268 }
269 Ok(())
270 })();
271
272 if let Err(e) = result {
273 if !overwrote_same_path {
286 let _ = self.inner.cache.delete(&stored.path);
287 }
288 return Err(e);
289 }
290
291 if let Some(old_path) = deferred_delete {
293 let _ = self.inner.cache.delete(&old_path);
294 }
295
296 Ok(asset)
297 }
298
299 pub fn get(&self, asset_id: &str) -> Result<Option<ResolvedAsset>> {
307 let mut index = self.state_lock()?;
308 let (abs_path, remove_stale) = match index.get(asset_id) {
310 Some(asset) => {
311 match resolve_under_root(&self.inner.config.cache_dir, &asset.local_path) {
312 Some(abs) => (Some(abs), false),
313 None => {
314 tracing::warn!(
315 asset_id,
316 path = ?asset.local_path,
317 "dropping index entry with unsafe local_path",
318 );
319 (None, true)
320 }
321 }
322 }
323 None => return Ok(None),
324 };
325
326 if remove_stale {
327 index.remove(asset_id);
328 index.save(&self.inner.config.cache_dir)?;
329 return Ok(None);
330 }
331 let abs_path = abs_path.expect("abs_path set when remove_stale is false");
332
333 if !abs_path.is_file() {
334 index.remove(asset_id);
336 index.save(&self.inner.config.cache_dir)?;
337 return Ok(None);
338 }
339
340 index.touch(asset_id);
343 index.save(&self.inner.config.cache_dir)?;
344 let asset = index
345 .get(asset_id)
346 .cloned()
347 .expect("asset still present after touch");
348 Ok(Some(ResolvedAsset {
349 asset,
350 absolute_path: abs_path,
351 }))
352 }
353
354 pub fn delete(&self, asset_id: &str) -> Result<bool> {
357 let mut index = self.state_lock()?;
358 let Some(asset) = index.remove(asset_id) else {
359 return Ok(false);
360 };
361 if let Some(abs_path) = resolve_under_root(&self.inner.config.cache_dir, &asset.local_path)
362 {
363 self.inner.cache.delete(&abs_path)?;
364 } else {
365 tracing::warn!(
366 asset_id,
367 path = ?asset.local_path,
368 "skipping filesystem delete for unsafe local_path",
369 );
370 }
371 index.save(&self.inner.config.cache_dir)?;
372 Ok(true)
373 }
374
375 pub fn list(&self) -> Result<Vec<CachedAsset>> {
381 Ok(self.state_lock()?.assets.values().cloned().collect())
382 }
383
384 pub fn total_size(&self) -> Result<u64> {
389 Ok(self.state_lock()?.total_size())
390 }
391
392 pub fn rotate_now(&self) -> Result<RotationStats> {
394 let mut index = self.state_lock()?;
395 let stats = self.inner.rotator.rotate(&mut index, &self.inner.cache)?;
396 index.save(&self.inner.config.cache_dir)?;
397 Ok(stats)
398 }
399
400 pub fn integrity_check(&self) -> Result<usize> {
402 let mut index = self.state_lock()?;
403 let removed = prune_stale_entries(&mut index, &self.inner.cache);
404 if removed > 0 {
405 index.save(&self.inner.config.cache_dir)?;
406 }
407 Ok(removed)
408 }
409
410 pub fn index_path(&self) -> PathBuf {
412 self.inner.config.cache_dir.join(INDEX_FILENAME)
413 }
414
415 pub fn content_id(data: &[u8]) -> String {
429 let hash = crate::cache::sha256_hex(data);
430 format!("sha256:{}", &hash[..16])
431 }
432
433 fn state_lock(&self) -> Result<std::sync::MutexGuard<'_, AssetIndex>> {
439 self.inner
440 .state
441 .lock()
442 .map_err(|e| AssetError::poisoned(e.to_string()))
443 }
444}
445
446#[derive(Debug, Clone)]
449pub struct ResolvedAsset {
450 pub asset: CachedAsset,
452 pub absolute_path: PathBuf,
454}
455
456#[derive(Debug)]
458pub struct StoreRequest<'a> {
459 pub context: AssetContext,
461 pub asset_id: Option<&'a str>,
477 pub filename: &'a str,
478 pub mime_type: Option<String>,
480 pub remote_url: Option<String>,
482 pub data: &'a [u8],
484}
485
486const MAX_ASSET_ID_LEN: usize = 200;
490
491fn prune_stale_entries(index: &mut AssetIndex, cache: &CacheManager) -> usize {
497 let stale: Vec<String> = index
498 .assets
499 .iter()
500 .filter_map(
501 |(id, asset)| match resolve_under_root(cache.root(), &asset.local_path) {
502 Some(abs) if cache.exists(&abs) => None,
503 Some(_) => Some(id.clone()),
504 None => {
505 tracing::warn!(
506 asset_id = id.as_str(),
507 path = ?asset.local_path,
508 "dropping index entry with unsafe local_path",
509 );
510 Some(id.clone())
511 }
512 },
513 )
514 .collect();
515 let count = stale.len();
516 for id in stale {
517 index.remove(&id);
518 }
519 count
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::config::EvictionPolicy;
526 use devboy_core::asset::AssetContext;
527 use std::time::Duration;
528 use tempfile::tempdir;
529
530 fn manager(root: PathBuf) -> AssetManager {
531 let cfg = ResolvedAssetConfig {
532 cache_dir: root,
533 max_cache_size: 10_000,
534 max_file_age: Duration::from_secs(100 * 86_400),
535 eviction_policy: EvictionPolicy::Lru,
536 };
537 AssetManager::from_resolved(cfg).unwrap()
538 }
539
540 fn store_simple<'a>(
541 context: AssetContext,
542 asset_id: &'a str,
543 filename: &'a str,
544 data: &'a [u8],
545 ) -> StoreRequest<'a> {
546 StoreRequest {
547 context,
548 asset_id: Some(asset_id),
549 filename,
550 mime_type: None,
551 remote_url: None,
552 data,
553 }
554 }
555
556 #[test]
557 fn store_get_delete_roundtrip() {
558 let tmp = tempdir().unwrap();
559 let mgr = manager(tmp.path().to_path_buf());
560 let ctx = AssetContext::Issue {
561 key: "DEV-1".into(),
562 };
563
564 let asset = mgr
565 .store(StoreRequest {
566 context: ctx.clone(),
567 asset_id: Some("a1"),
568 filename: "file.txt",
569 mime_type: Some("text/plain".into()),
570 remote_url: None,
571 data: b"hello",
572 })
573 .unwrap();
574 assert_eq!(asset.size, 5);
575 assert_eq!(mgr.total_size().unwrap(), 5);
576
577 let resolved = mgr.get("a1").unwrap().expect("asset present");
578 assert_eq!(resolved.asset.id, "a1");
579 assert!(resolved.absolute_path.is_file());
580 assert_eq!(std::fs::read(&resolved.absolute_path).unwrap(), b"hello");
581
582 assert!(mgr.delete("a1").unwrap());
583 assert!(mgr.get("a1").unwrap().is_none());
584 assert!(!mgr.delete("a1").unwrap(), "second delete is a no-op");
585 assert_eq!(mgr.total_size().unwrap(), 0);
586 }
587
588 #[test]
589 fn store_persists_across_reopen() {
590 let tmp = tempdir().unwrap();
591 {
592 let mgr = manager(tmp.path().to_path_buf());
593 let ctx = AssetContext::Issue {
594 key: "DEV-1".into(),
595 };
596 mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
597 }
598
599 let mgr = manager(tmp.path().to_path_buf());
600 let list = mgr.list().unwrap();
601 assert_eq!(list.len(), 1);
602 assert_eq!(list[0].id, "a1");
603 let resolved = mgr.get("a1").unwrap().unwrap();
604 assert_eq!(std::fs::read(&resolved.absolute_path).unwrap(), b"xyz");
605 }
606
607 #[test]
608 fn integrity_check_removes_missing_files() {
609 let tmp = tempdir().unwrap();
610 let mgr = manager(tmp.path().to_path_buf());
611 let ctx = AssetContext::Issue {
612 key: "DEV-1".into(),
613 };
614 let asset = mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
615
616 let abs = tmp.path().join(&asset.local_path);
618 std::fs::remove_file(&abs).unwrap();
619
620 let removed = mgr.integrity_check().unwrap();
621 assert_eq!(removed, 1);
622 assert!(mgr.list().unwrap().is_empty());
623 }
624
625 #[test]
626 fn get_drops_stale_entry_and_returns_none() {
627 let tmp = tempdir().unwrap();
628 let mgr = manager(tmp.path().to_path_buf());
629 let ctx = AssetContext::Issue {
630 key: "DEV-1".into(),
631 };
632 let asset = mgr.store(store_simple(ctx, "a1", "x.bin", b"xyz")).unwrap();
633
634 std::fs::remove_file(tmp.path().join(&asset.local_path)).unwrap();
635
636 assert!(mgr.get("a1").unwrap().is_none());
637 assert!(mgr.list().unwrap().is_empty());
638 }
639
640 #[test]
641 fn rotate_now_enforces_budget() {
642 let tmp = tempdir().unwrap();
643 let cfg = ResolvedAssetConfig {
644 cache_dir: tmp.path().to_path_buf(),
645 max_cache_size: 100,
646 max_file_age: Duration::from_secs(100 * 86_400),
647 eviction_policy: EvictionPolicy::Lru,
648 };
649 let mgr = AssetManager::from_resolved(cfg).unwrap();
650 let ctx = AssetContext::Issue {
651 key: "DEV-1".into(),
652 };
653
654 mgr.store(store_simple(ctx.clone(), "a", "a.bin", &[0u8; 60]))
655 .unwrap();
656 mgr.store(store_simple(ctx, "b", "b.bin", &[0u8; 60]))
659 .unwrap();
660 assert!(mgr.total_size().unwrap() <= 100);
661 assert_eq!(mgr.list().unwrap().len(), 1);
662
663 let stats = mgr.rotate_now().unwrap();
665 assert_eq!(stats.removed(), 0);
666 }
667
668 #[test]
669 fn index_path_points_at_cache_dir() {
670 let tmp = tempdir().unwrap();
671 let mgr = manager(tmp.path().to_path_buf());
672 assert_eq!(mgr.index_path(), tmp.path().join(INDEX_FILENAME));
673 assert_eq!(mgr.cache_dir(), tmp.path());
674 }
675
676 #[test]
677 fn store_treats_zero_max_cache_size_as_unlimited() {
678 let tmp = tempdir().unwrap();
679 let cfg = ResolvedAssetConfig {
680 cache_dir: tmp.path().to_path_buf(),
681 max_cache_size: 0, max_file_age: Duration::from_secs(100 * 86_400),
683 eviction_policy: EvictionPolicy::Lru,
684 };
685 let mgr = AssetManager::from_resolved(cfg).unwrap();
686 let ctx = AssetContext::Issue {
687 key: "DEV-1".into(),
688 };
689
690 let big = vec![0u8; 2_000_000];
693 mgr.store(store_simple(ctx, "big", "big.bin", &big))
694 .unwrap();
695 assert_eq!(mgr.total_size().unwrap(), big.len() as u64);
696 assert_eq!(mgr.list().unwrap().len(), 1);
697
698 let stats = mgr.rotate_now().unwrap();
700 assert_eq!(stats.removed(), 0);
701 }
702
703 #[test]
704 fn store_rejects_oversized_payload() {
705 let tmp = tempdir().unwrap();
706 let cfg = ResolvedAssetConfig {
707 cache_dir: tmp.path().to_path_buf(),
708 max_cache_size: 10,
709 max_file_age: Duration::from_secs(100 * 86_400),
710 eviction_policy: EvictionPolicy::Lru,
711 };
712 let mgr = AssetManager::from_resolved(cfg).unwrap();
713 let ctx = AssetContext::Issue {
714 key: "DEV-1".into(),
715 };
716
717 let err = mgr
718 .store(store_simple(ctx, "a1", "big.bin", &[0u8; 100]))
719 .unwrap_err();
720 let msg = err.to_string();
721 assert!(msg.contains("exceeds the cache"), "unexpected msg: {msg}");
722
723 assert!(mgr.list().unwrap().is_empty());
725 assert_eq!(mgr.total_size().unwrap(), 0);
726 }
727
728 #[test]
729 fn get_returns_fresh_last_accessed() {
730 let tmp = tempdir().unwrap();
731 let mgr = manager(tmp.path().to_path_buf());
732 let ctx = AssetContext::Issue {
733 key: "DEV-1".into(),
734 };
735 let stored = mgr.store(store_simple(ctx, "a1", "a.bin", b"xyz")).unwrap();
736 let stored_at = stored.last_accessed_ms;
737
738 std::thread::sleep(std::time::Duration::from_millis(5));
740
741 let resolved = mgr.get("a1").unwrap().expect("asset present");
742 assert!(
743 resolved.asset.last_accessed_ms > stored_at,
744 "expected ResolvedAsset to reflect the post-touch timestamp: \
745 stored={stored_at}, returned={}",
746 resolved.asset.last_accessed_ms,
747 );
748
749 let from_list = mgr
751 .list()
752 .unwrap()
753 .into_iter()
754 .find(|a| a.id == "a1")
755 .unwrap();
756 assert_eq!(from_list.last_accessed_ms, resolved.asset.last_accessed_ms);
757 }
758
759 #[test]
760 fn from_resolved_rotates_on_startup() {
761 let tmp = tempdir().unwrap();
762
763 {
765 let mgr = manager(tmp.path().to_path_buf());
766 let ctx = AssetContext::Issue {
767 key: "DEV-1".into(),
768 };
769 mgr.store(store_simple(ctx.clone(), "a", "a.bin", &[0u8; 60]))
770 .unwrap();
771 mgr.store(store_simple(ctx, "b", "b.bin", &[0u8; 60]))
772 .unwrap();
773 assert_eq!(mgr.total_size().unwrap(), 120);
774 }
775
776 let tight = ResolvedAssetConfig {
779 cache_dir: tmp.path().to_path_buf(),
780 max_cache_size: 100,
781 max_file_age: Duration::from_secs(100 * 86_400),
782 eviction_policy: EvictionPolicy::Lru,
783 };
784 let mgr = AssetManager::from_resolved(tight).unwrap();
785 assert!(
786 mgr.total_size().unwrap() <= 100,
787 "cache still over budget on open"
788 );
789 assert_eq!(mgr.list().unwrap().len(), 1);
790 }
791
792 #[test]
793 fn with_root_uses_defaults() {
794 let tmp = tempdir().unwrap();
795 let mgr = AssetManager::with_root(tmp.path().to_path_buf()).unwrap();
796 assert_eq!(mgr.cache_dir(), tmp.path());
797 assert!(mgr.config().max_cache_size > 0);
798 }
799
800 #[test]
805 fn store_auto_generates_content_addressed_id() {
806 let tmp = tempdir().unwrap();
807 let mgr = manager(tmp.path().to_path_buf());
808 let ctx = AssetContext::Issue {
809 key: "DEV-1".into(),
810 };
811
812 let asset = mgr
813 .store(StoreRequest {
814 context: ctx,
815 asset_id: None, filename: "trace.log",
817 mime_type: None,
818 remote_url: None,
819 data: b"stack trace here",
820 })
821 .unwrap();
822
823 assert!(
824 asset.id.starts_with("sha256:"),
825 "auto-generated id should have sha256: prefix, got: {}",
826 asset.id,
827 );
828 assert_eq!(asset.id.len(), 7 + 16); let expected = AssetManager::content_id(b"stack trace here");
832 assert_eq!(asset.id, expected);
833
834 let resolved = mgr.get(&asset.id).unwrap().expect("should be cached");
836 assert_eq!(
837 std::fs::read(&resolved.absolute_path).unwrap(),
838 b"stack trace here",
839 );
840 }
841
842 #[test]
843 fn store_deduplicates_by_content_id() {
844 let tmp = tempdir().unwrap();
845 let mgr = manager(tmp.path().to_path_buf());
846 let ctx = AssetContext::Issue {
847 key: "DEV-1".into(),
848 };
849
850 let a = mgr
851 .store(StoreRequest {
852 context: ctx.clone(),
853 asset_id: None,
854 filename: "a.log",
855 mime_type: None,
856 remote_url: None,
857 data: b"same content",
858 })
859 .unwrap();
860
861 let b = mgr
862 .store(StoreRequest {
863 context: ctx,
864 asset_id: None,
865 filename: "b.log",
866 mime_type: None,
867 remote_url: None,
868 data: b"same content",
869 })
870 .unwrap();
871
872 assert_eq!(a.id, b.id);
874 assert_eq!(mgr.list().unwrap().len(), 1);
875 }
876
877 #[test]
878 fn store_rejects_empty_asset_id() {
879 let tmp = tempdir().unwrap();
880 let mgr = manager(tmp.path().to_path_buf());
881 let ctx = AssetContext::Issue {
882 key: "DEV-1".into(),
883 };
884
885 let err = mgr
886 .store(StoreRequest {
887 context: ctx,
888 asset_id: Some(""),
889 filename: "x.txt",
890 mime_type: None,
891 remote_url: None,
892 data: b"x",
893 })
894 .unwrap_err();
895 assert!(err.to_string().contains("empty"), "unexpected: {err}");
896 }
897
898 #[test]
899 fn store_rejects_overly_long_asset_id() {
900 let tmp = tempdir().unwrap();
901 let mgr = manager(tmp.path().to_path_buf());
902 let ctx = AssetContext::Issue {
903 key: "DEV-1".into(),
904 };
905
906 let long_id = "x".repeat(MAX_ASSET_ID_LEN + 1);
907 let err = mgr
908 .store(StoreRequest {
909 context: ctx,
910 asset_id: Some(&long_id),
911 filename: "x.txt",
912 mime_type: None,
913 remote_url: None,
914 data: b"x",
915 })
916 .unwrap_err();
917 assert!(err.to_string().contains("200"), "unexpected: {err}");
918 }
919
920 #[test]
921 fn content_id_is_deterministic() {
922 let a = AssetManager::content_id(b"hello");
923 let b = AssetManager::content_id(b"hello");
924 assert_eq!(a, b);
925 assert!(a.starts_with("sha256:"));
926
927 let c = AssetManager::content_id(b"world");
928 assert_ne!(a, c);
929 }
930}