Skip to main content

aptu_core/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! TTL-based file caching for GitHub API responses.
4//!
5//! Stores issue and repository data as JSON files with embedded metadata
6//! (timestamp, optional etag). Cache entries are validated against TTL settings
7//! from configuration.
8
9// `async_yields_async` is suppressed because the FileCache trait uses async fn (RPITIT,
10// stable in Rust 1.95 / edition 2024). The trait is intentionally crate-internal
11// (not part of the public API) and is never used as `dyn FileCache`, so the lint
12// warning is a false positive. There is no plan to expose this trait publicly.
13
14use std::path::PathBuf;
15use std::sync::OnceLock;
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Duration, Utc};
19use serde::{Deserialize, Serialize};
20#[cfg(test)]
21use tracing::debug;
22use tracing::warn;
23
24/// Ensures the cache unavailable warning is only emitted once.
25static CACHE_UNAVAILABLE_WARNING: OnceLock<()> = OnceLock::new();
26
27/// Default TTL for issue cache entries (in minutes).
28pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
29
30/// Default TTL for repository cache entries (in hours).
31pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
32
33/// Default TTL for model registry cache entries (in seconds).
34pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
35
36/// Default TTL for security finding cache entries (in days).
37pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
38
39/// A cached entry with metadata.
40///
41/// Wraps cached data with timestamp and optional etag for validation.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub(crate) struct CacheEntry<T> {
44    /// The cached data.
45    pub data: T,
46    /// When the entry was cached.
47    pub cached_at: DateTime<Utc>,
48    /// Optional `ETag` for future conditional requests.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub etag: Option<String>,
51}
52
53impl<T> CacheEntry<T> {
54    /// Create a new cache entry.
55    pub fn new(data: T) -> Self {
56        Self {
57            data,
58            cached_at: Utc::now(),
59            etag: None,
60        }
61    }
62
63    /// Create a new cache entry with an etag.
64    #[cfg(test)]
65    pub fn with_etag(data: T, etag: String) -> Self {
66        Self {
67            data,
68            cached_at: Utc::now(),
69            etag: Some(etag),
70        }
71    }
72
73    /// Check if this entry is still valid based on TTL.
74    ///
75    /// # Arguments
76    ///
77    /// * `ttl` - Time-to-live duration
78    ///
79    /// # Returns
80    ///
81    /// `true` if the entry is within its TTL, `false` if expired.
82    pub fn is_valid(&self, ttl: Duration) -> bool {
83        let now = Utc::now();
84        now.signed_duration_since(self.cached_at) < ttl
85    }
86}
87
88/// Returns the cache directory.
89///
90/// - Linux: `~/.cache/aptu`
91/// - macOS: `~/Library/Caches/aptu`
92/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
93///
94/// Returns `None` if the cache directory cannot be determined.
95#[must_use]
96pub fn cache_dir() -> Option<PathBuf> {
97    dirs::cache_dir().map(|dir| dir.join("aptu"))
98}
99
100/// Trait for TTL-based filesystem caching.
101///
102/// Provides a unified interface for caching serializable data with time-to-live validation.
103///
104/// `async_fn_in_trait` is suppressed because this trait is re-exported for use by crate
105/// consumers but is never intended to be implemented externally or used as `dyn FileCache`.
106/// All known implementors are in this crate, so auto-trait bounds are not a concern.
107#[allow(async_fn_in_trait)]
108pub(crate) trait FileCache<V> {
109    /// Get a cached value if it exists and is valid.
110    ///
111    /// # Arguments
112    ///
113    /// * `key` - Cache key (filename without extension)
114    ///
115    /// # Returns
116    ///
117    /// The cached value if it exists and is within TTL, `None` otherwise.
118    async fn get(&self, key: &str) -> Result<Option<V>>;
119
120    /// Get a cached value regardless of TTL (stale fallback).
121    ///
122    /// # Arguments
123    ///
124    /// * `key` - Cache key (filename without extension)
125    ///
126    /// # Returns
127    ///
128    /// The cached value if it exists, `None` otherwise.
129    async fn get_stale(&self, key: &str) -> Result<Option<V>>;
130
131    /// Set a cached value.
132    ///
133    /// # Arguments
134    ///
135    /// * `key` - Cache key (filename without extension)
136    /// * `value` - Value to cache
137    async fn set(&self, key: &str, value: &V) -> Result<()>;
138
139    /// Remove a cached value.
140    ///
141    /// # Arguments
142    ///
143    /// * `key` - Cache key (filename without extension)
144    #[cfg(test)]
145    async fn remove(&self, key: &str) -> Result<()>;
146}
147
148/// File-based cache implementation with TTL support.
149///
150/// Stores serialized data in JSON files with embedded metadata.
151/// When cache directory is unavailable (None), all operations become no-ops.
152pub(crate) struct FileCacheImpl<V> {
153    cache_dir: Option<PathBuf>,
154    ttl: Duration,
155    subdirectory: String,
156    _phantom: std::marker::PhantomData<V>,
157}
158
159impl<V> FileCacheImpl<V>
160where
161    V: Serialize + for<'de> Deserialize<'de>,
162{
163    /// Create a new file cache with default cache directory.
164    ///
165    /// # Arguments
166    ///
167    /// * `subdirectory` - Subdirectory within cache directory
168    /// * `ttl` - Time-to-live for cache entries
169    ///
170    /// If the cache directory cannot be determined, caching is disabled
171    /// and a warning is emitted.
172    #[must_use]
173    pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
174        let cache_dir = cache_dir();
175        if cache_dir.is_none() {
176            CACHE_UNAVAILABLE_WARNING.get_or_init(|| {
177                warn!("Cache directory unavailable, caching disabled");
178            });
179        }
180        Self::with_dir(cache_dir, subdirectory, ttl)
181    }
182
183    /// Create a new file cache with custom cache directory.
184    ///
185    /// # Arguments
186    ///
187    /// * `cache_dir` - Base cache directory (None to disable caching)
188    /// * `subdirectory` - Subdirectory within cache directory
189    /// * `ttl` - Time-to-live for cache entries
190    #[must_use]
191    pub fn with_dir(
192        cache_dir: Option<PathBuf>,
193        subdirectory: impl Into<String>,
194        ttl: Duration,
195    ) -> Self {
196        Self {
197            cache_dir,
198            ttl,
199            subdirectory: subdirectory.into(),
200            _phantom: std::marker::PhantomData,
201        }
202    }
203
204    /// Check if caching is enabled.
205    fn is_enabled(&self) -> bool {
206        self.cache_dir.is_some()
207    }
208
209    /// Get the full path for a cache key.
210    ///
211    /// Returns `None` if the key contains path separators or parent directory
212    /// references, which could lead to path traversal vulnerabilities.
213    fn cache_path(&self, key: &str) -> Option<PathBuf> {
214        // Validate key to prevent path traversal; return None for invalid keys.
215        if key.contains('/') || key.contains('\\') || key.contains("..") {
216            return None;
217        }
218
219        let filename = if std::path::Path::new(key)
220            .extension()
221            .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
222        {
223            key.to_string()
224        } else {
225            format!("{key}.json")
226        };
227        self.cache_dir
228            .as_ref()
229            .map(|dir| dir.join(&self.subdirectory).join(filename))
230    }
231
232    /// Evict cache files older than the specified TTL.
233    ///
234    /// Scans the cache subdirectory and removes files with `cached_at` timestamps
235    /// older than `eviction_days`. Returns the count of files removed.
236    ///
237    /// # Arguments
238    ///
239    /// * `eviction_days` - Number of days to retain files
240    ///
241    /// # Returns
242    ///
243    /// The number of files evicted.
244    #[cfg(test)]
245    pub async fn evict_stale(&self, eviction_days: i64) -> usize {
246        if !self.is_enabled() {
247            return 0;
248        }
249
250        let Some(cache_dir) = &self.cache_dir else {
251            return 0;
252        };
253
254        let subdir = cache_dir.join(&self.subdirectory);
255
256        // Check if subdirectory exists
257        if !tokio::fs::try_exists(&subdir).await.unwrap_or(false) {
258            return 0;
259        }
260
261        let Ok(mut read_dir) = tokio::fs::read_dir(&subdir).await else {
262            return 0;
263        };
264
265        let mut evicted_count = 0;
266        let cutoff_time = Utc::now() - Duration::days(eviction_days);
267
268        while let Ok(Some(entry)) = read_dir.next_entry().await {
269            let path = entry.path();
270
271            // Only process .json files
272            if !path
273                .extension()
274                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
275            {
276                continue;
277            }
278
279            let Ok(contents) = tokio::fs::read_to_string(&path).await else {
280                continue;
281            };
282
283            let Ok(entry_data) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&contents)
284            else {
285                continue;
286            };
287
288            if entry_data.cached_at < cutoff_time && tokio::fs::remove_file(&path).await.is_ok() {
289                debug!("Evicted stale cache file: {}", path.display());
290                evicted_count += 1;
291            }
292        }
293
294        evicted_count
295    }
296}
297
298impl<V> FileCache<V> for FileCacheImpl<V>
299where
300    V: Serialize + for<'de> Deserialize<'de>,
301{
302    async fn get(&self, key: &str) -> Result<Option<V>> {
303        if !self.is_enabled() {
304            return Ok(None);
305        }
306
307        let Some(path) = self.cache_path(key) else {
308            return Ok(None);
309        };
310
311        if !tokio::fs::try_exists(&path)
312            .await
313            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
314        {
315            return Ok(None);
316        }
317
318        let contents = tokio::fs::read_to_string(&path)
319            .await
320            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
321
322        let entry: CacheEntry<V> = serde_json::from_str(&contents)
323            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
324
325        if entry.is_valid(self.ttl) {
326            Ok(Some(entry.data))
327        } else {
328            Ok(None)
329        }
330    }
331
332    async fn get_stale(&self, key: &str) -> Result<Option<V>> {
333        if !self.is_enabled() {
334            return Ok(None);
335        }
336
337        let Some(path) = self.cache_path(key) else {
338            return Ok(None);
339        };
340
341        if !tokio::fs::try_exists(&path)
342            .await
343            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
344        {
345            return Ok(None);
346        }
347
348        let contents = tokio::fs::read_to_string(&path)
349            .await
350            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
351
352        let entry: CacheEntry<V> = serde_json::from_str(&contents)
353            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
354
355        Ok(Some(entry.data))
356    }
357
358    async fn set(&self, key: &str, value: &V) -> Result<()> {
359        if !self.is_enabled() {
360            return Ok(());
361        }
362
363        let Some(path) = self.cache_path(key) else {
364            return Ok(());
365        };
366
367        // Create parent directories if needed
368        if let Some(parent) = path.parent() {
369            tokio::fs::create_dir_all(parent).await.with_context(|| {
370                format!("Failed to create cache directory: {}", parent.display())
371            })?;
372        }
373
374        let entry = CacheEntry::new(value);
375        let contents =
376            serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
377
378        // Atomic write: write to temp file, then rename
379        let temp_path = path.with_extension("tmp");
380        tokio::fs::write(&temp_path, contents)
381            .await
382            .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
383
384        tokio::fs::rename(&temp_path, &path)
385            .await
386            .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
387
388        Ok(())
389    }
390
391    #[cfg(test)]
392    async fn remove(&self, key: &str) -> Result<()> {
393        if !self.is_enabled() {
394            return Ok(());
395        }
396
397        let Some(path) = self.cache_path(key) else {
398            return Ok(());
399        };
400
401        if tokio::fs::try_exists(&path)
402            .await
403            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
404        {
405            tokio::fs::remove_file(&path)
406                .await
407                .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
408        }
409        Ok(())
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418    struct TestData {
419        value: String,
420        count: u32,
421    }
422
423    #[test]
424    fn test_cache_entry_new() {
425        let data = TestData {
426            value: "test".to_string(),
427            count: 42,
428        };
429        let entry = CacheEntry::new(data.clone());
430
431        assert_eq!(entry.data, data);
432        assert!(entry.etag.is_none());
433    }
434
435    #[test]
436    fn test_cache_entry_with_etag() {
437        let data = TestData {
438            value: "test".to_string(),
439            count: 42,
440        };
441        let etag = "abc123".to_string();
442        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
443
444        assert_eq!(entry.data, data);
445        assert_eq!(entry.etag, Some(etag));
446    }
447
448    #[test]
449    fn test_cache_entry_is_valid_within_ttl() {
450        let data = TestData {
451            value: "test".to_string(),
452            count: 42,
453        };
454        let entry = CacheEntry::new(data);
455        let ttl = Duration::hours(1);
456
457        assert!(entry.is_valid(ttl));
458    }
459
460    #[test]
461    fn test_cache_entry_is_valid_expired() {
462        let data = TestData {
463            value: "test".to_string(),
464            count: 42,
465        };
466        let mut entry = CacheEntry::new(data);
467        // Manually set cached_at to 2 hours ago
468        entry.cached_at = Utc::now() - Duration::hours(2);
469        let ttl = Duration::hours(1);
470
471        assert!(!entry.is_valid(ttl));
472    }
473
474    #[test]
475    fn test_cache_dir_path() {
476        let dir = cache_dir();
477        assert!(dir.is_some());
478        assert!(dir.unwrap().ends_with("aptu"));
479    }
480
481    #[test]
482    fn test_cache_serialization_with_etag() {
483        let data = TestData {
484            value: "test".to_string(),
485            count: 42,
486        };
487        let etag = "xyz789".to_string();
488        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
489
490        let json = serde_json::to_string(&entry).expect("serialize");
491        let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
492
493        assert_eq!(parsed.data, data);
494        assert_eq!(parsed.etag, Some(etag));
495    }
496
497    #[tokio::test]
498    async fn test_file_cache_get_set() {
499        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
500        let data = TestData {
501            value: "test".to_string(),
502            count: 42,
503        };
504
505        // Set value
506        cache.set("test_key", &data).await.expect("set cache");
507
508        // Get value
509        let result = cache.get("test_key").await.expect("get cache");
510        assert!(result.is_some());
511        assert_eq!(result.unwrap(), data);
512
513        // Cleanup
514        cache.remove("test_key").await.ok();
515    }
516
517    #[tokio::test]
518    async fn test_file_cache_get_miss() {
519        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
520
521        let result = cache.get("nonexistent").await.expect("get cache");
522        assert!(result.is_none());
523    }
524
525    #[tokio::test]
526    async fn test_file_cache_get_stale() {
527        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
528        let data = TestData {
529            value: "stale".to_string(),
530            count: 99,
531        };
532
533        // Set value
534        cache.set("stale_key", &data).await.expect("set cache");
535
536        // Wait for TTL to expire
537        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
538
539        // get() should return None (expired)
540        let result = cache.get("stale_key").await.expect("get cache");
541        assert!(result.is_none());
542
543        // get_stale() should return the value
544        let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
545        assert!(stale_result.is_some());
546        assert_eq!(stale_result.unwrap(), data);
547
548        // Cleanup
549        cache.remove("stale_key").await.ok();
550    }
551
552    #[tokio::test]
553    async fn test_file_cache_remove() {
554        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
555        let data = TestData {
556            value: "remove_me".to_string(),
557            count: 1,
558        };
559
560        // Set value
561        cache.set("remove_key", &data).await.expect("set cache");
562
563        // Verify it exists
564        assert!(cache.get("remove_key").await.expect("get cache").is_some());
565
566        // Remove it
567        cache.remove("remove_key").await.expect("remove cache");
568
569        // Verify it's gone
570        assert!(cache.get("remove_key").await.expect("get cache").is_none());
571    }
572
573    #[tokio::test]
574    async fn test_cache_key_rejects_forward_slash() {
575        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
576        let result = cache
577            .get("../etc/passwd")
578            .await
579            .expect("get should succeed");
580        assert!(result.is_none());
581    }
582
583    #[tokio::test]
584    async fn test_cache_key_rejects_backslash() {
585        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
586        let data = TestData {
587            value: "x".to_string(),
588            count: 0,
589        };
590        let result = cache
591            .set("..\\windows\\system32", &data)
592            .await
593            .expect("set should succeed silently");
594        assert_eq!(result, ());
595    }
596
597    #[tokio::test]
598    async fn test_cache_key_rejects_parent_dir() {
599        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
600        let result = cache.get("foo..bar").await.expect("get should succeed");
601        assert!(result.is_none());
602    }
603
604    #[tokio::test]
605    async fn test_disabled_cache_get_returns_none() {
606        let cache: FileCacheImpl<TestData> =
607            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
608        let result = cache.get("any_key").await.expect("get should succeed");
609        assert!(result.is_none());
610    }
611
612    #[tokio::test]
613    async fn test_disabled_cache_set_succeeds_silently() {
614        let cache: FileCacheImpl<TestData> =
615            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
616        let data = TestData {
617            value: "test".to_string(),
618            count: 42,
619        };
620        cache
621            .set("any_key", &data)
622            .await
623            .expect("set should succeed");
624    }
625
626    #[tokio::test]
627    async fn test_disabled_cache_remove_succeeds_silently() {
628        let cache: FileCacheImpl<TestData> =
629            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
630        cache
631            .remove("any_key")
632            .await
633            .expect("remove should succeed");
634    }
635
636    #[tokio::test]
637    async fn test_disabled_cache_get_stale_returns_none() {
638        let cache: FileCacheImpl<TestData> =
639            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
640        let result = cache
641            .get_stale("any_key")
642            .await
643            .expect("get_stale should succeed");
644        assert!(result.is_none());
645    }
646
647    #[tokio::test]
648    async fn test_evict_stale_removes_old_files() {
649        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
650        let data = TestData {
651            value: "old".to_string(),
652            count: 1,
653        };
654
655        // Set a value
656        cache.set("old_key", &data).await.expect("set cache");
657
658        // Manually modify the cached_at timestamp to be old
659        if let Some(path) = cache.cache_path("old_key") {
660            let contents = tokio::fs::read_to_string(&path)
661                .await
662                .expect("read cache file");
663            let mut entry: CacheEntry<TestData> =
664                serde_json::from_str(&contents).expect("parse cache entry");
665            entry.cached_at = Utc::now() - Duration::days(10);
666            let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
667            tokio::fs::write(&path, new_contents)
668                .await
669                .expect("write cache file");
670        }
671
672        // Evict files older than 7 days
673        let evicted = cache.evict_stale(7).await;
674        assert_eq!(evicted, 1);
675
676        // Verify the file is gone
677        let result = cache.get("old_key").await.expect("get cache");
678        assert!(result.is_none());
679    }
680
681    #[tokio::test]
682    async fn test_evict_stale_preserves_fresh_files() {
683        let cache: FileCacheImpl<TestData> =
684            FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
685        let data = TestData {
686            value: "fresh".to_string(),
687            count: 2,
688        };
689
690        // Set a value
691        cache.set("fresh_key", &data).await.expect("set cache");
692
693        // Evict files older than 7 days (this file is fresh, so it should be preserved)
694        let evicted = cache.evict_stale(7).await;
695        assert_eq!(evicted, 0);
696
697        // Verify the file still exists
698        let result = cache.get("fresh_key").await.expect("get cache");
699        assert!(result.is_some());
700        assert_eq!(result.unwrap(), data);
701
702        // Cleanup
703        cache.remove("fresh_key").await.ok();
704    }
705}