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