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
9use std::fs;
10use std::path::PathBuf;
11use std::sync::Once;
12
13use anyhow::{Context, Result};
14use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16use tracing::warn;
17
18/// Ensures the cache unavailable warning is only emitted once.
19static CACHE_UNAVAILABLE_WARNING: Once = Once::new();
20
21/// Default TTL for issue cache entries (in minutes).
22pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
23
24/// Default TTL for repository cache entries (in hours).
25pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
26
27/// Default TTL for model registry cache entries (in seconds).
28pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
29
30/// Default TTL for security finding cache entries (in days).
31pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
32
33/// A cached entry with metadata.
34///
35/// Wraps cached data with timestamp and optional etag for validation.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CacheEntry<T> {
38    /// The cached data.
39    pub data: T,
40    /// When the entry was cached.
41    pub cached_at: DateTime<Utc>,
42    /// Optional `ETag` for future conditional requests.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub etag: Option<String>,
45}
46
47impl<T> CacheEntry<T> {
48    /// Create a new cache entry.
49    pub fn new(data: T) -> Self {
50        Self {
51            data,
52            cached_at: Utc::now(),
53            etag: None,
54        }
55    }
56
57    /// Create a new cache entry with an etag.
58    pub fn with_etag(data: T, etag: String) -> Self {
59        Self {
60            data,
61            cached_at: Utc::now(),
62            etag: Some(etag),
63        }
64    }
65
66    /// Check if this entry is still valid based on TTL.
67    ///
68    /// # Arguments
69    ///
70    /// * `ttl` - Time-to-live duration
71    ///
72    /// # Returns
73    ///
74    /// `true` if the entry is within its TTL, `false` if expired.
75    pub fn is_valid(&self, ttl: Duration) -> bool {
76        let now = Utc::now();
77        now.signed_duration_since(self.cached_at) < ttl
78    }
79}
80
81/// Returns the cache directory.
82///
83/// - Linux: `~/.cache/aptu`
84/// - macOS: `~/Library/Caches/aptu`
85/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
86///
87/// Returns `None` if the cache directory cannot be determined.
88#[must_use]
89pub fn cache_dir() -> Option<PathBuf> {
90    dirs::cache_dir().map(|dir| dir.join("aptu"))
91}
92
93/// Trait for TTL-based filesystem caching.
94///
95/// Provides a unified interface for caching serializable data with time-to-live validation.
96pub trait FileCache<V> {
97    /// Get a cached value if it exists and is valid.
98    ///
99    /// # Arguments
100    ///
101    /// * `key` - Cache key (filename without extension)
102    ///
103    /// # Returns
104    ///
105    /// The cached value if it exists and is within TTL, `None` otherwise.
106    fn get(&self, key: &str) -> Result<Option<V>>;
107
108    /// Get a cached value regardless of TTL (stale fallback).
109    ///
110    /// # Arguments
111    ///
112    /// * `key` - Cache key (filename without extension)
113    ///
114    /// # Returns
115    ///
116    /// The cached value if it exists, `None` otherwise.
117    fn get_stale(&self, key: &str) -> Result<Option<V>>;
118
119    /// Set a cached value.
120    ///
121    /// # Arguments
122    ///
123    /// * `key` - Cache key (filename without extension)
124    /// * `value` - Value to cache
125    fn set(&self, key: &str, value: &V) -> Result<()>;
126
127    /// Remove a cached value.
128    ///
129    /// # Arguments
130    ///
131    /// * `key` - Cache key (filename without extension)
132    fn remove(&self, key: &str) -> Result<()>;
133}
134
135/// File-based cache implementation with TTL support.
136///
137/// Stores serialized data in JSON files with embedded metadata.
138/// When cache directory is unavailable (None), all operations become no-ops.
139pub struct FileCacheImpl<V> {
140    cache_dir: Option<PathBuf>,
141    ttl: Duration,
142    subdirectory: String,
143    _phantom: std::marker::PhantomData<V>,
144}
145
146impl<V> FileCacheImpl<V>
147where
148    V: Serialize + for<'de> Deserialize<'de>,
149{
150    /// Create a new file cache with default cache directory.
151    ///
152    /// # Arguments
153    ///
154    /// * `subdirectory` - Subdirectory within cache directory
155    /// * `ttl` - Time-to-live for cache entries
156    ///
157    /// If the cache directory cannot be determined, caching is disabled
158    /// and a warning is emitted.
159    #[must_use]
160    pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
161        let cache_dir = cache_dir();
162        if cache_dir.is_none() {
163            CACHE_UNAVAILABLE_WARNING.call_once(|| {
164                warn!("Cache directory unavailable, caching disabled");
165            });
166        }
167        Self::with_dir(cache_dir, subdirectory, ttl)
168    }
169
170    /// Create a new file cache with custom cache directory.
171    ///
172    /// # Arguments
173    ///
174    /// * `cache_dir` - Base cache directory (None to disable caching)
175    /// * `subdirectory` - Subdirectory within cache directory
176    /// * `ttl` - Time-to-live for cache entries
177    #[must_use]
178    pub fn with_dir(
179        cache_dir: Option<PathBuf>,
180        subdirectory: impl Into<String>,
181        ttl: Duration,
182    ) -> Self {
183        Self {
184            cache_dir,
185            ttl,
186            subdirectory: subdirectory.into(),
187            _phantom: std::marker::PhantomData,
188        }
189    }
190
191    /// Check if caching is enabled.
192    fn is_enabled(&self) -> bool {
193        self.cache_dir.is_some()
194    }
195
196    /// Get the full path for a cache key.
197    ///
198    /// # Panics
199    ///
200    /// Panics if the key contains path separators or parent directory references,
201    /// which could lead to path traversal vulnerabilities.
202    fn cache_path(&self, key: &str) -> Option<PathBuf> {
203        // Validate key to prevent path traversal
204        assert!(
205            !key.contains('/') && !key.contains('\\') && !key.contains(".."),
206            "cache key must not contain path separators or '..': {key}"
207        );
208
209        let filename = if std::path::Path::new(key)
210            .extension()
211            .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
212        {
213            key.to_string()
214        } else {
215            format!("{key}.json")
216        };
217        self.cache_dir
218            .as_ref()
219            .map(|dir| dir.join(&self.subdirectory).join(filename))
220    }
221}
222
223impl<V> FileCache<V> for FileCacheImpl<V>
224where
225    V: Serialize + for<'de> Deserialize<'de>,
226{
227    fn get(&self, key: &str) -> Result<Option<V>> {
228        if !self.is_enabled() {
229            return Ok(None);
230        }
231
232        let Some(path) = self.cache_path(key) else {
233            return Ok(None);
234        };
235
236        if !path.exists() {
237            return Ok(None);
238        }
239
240        let contents = fs::read_to_string(&path)
241            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
242
243        let entry: CacheEntry<V> = serde_json::from_str(&contents)
244            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
245
246        if entry.is_valid(self.ttl) {
247            Ok(Some(entry.data))
248        } else {
249            Ok(None)
250        }
251    }
252
253    fn get_stale(&self, key: &str) -> Result<Option<V>> {
254        if !self.is_enabled() {
255            return Ok(None);
256        }
257
258        let Some(path) = self.cache_path(key) else {
259            return Ok(None);
260        };
261
262        if !path.exists() {
263            return Ok(None);
264        }
265
266        let contents = fs::read_to_string(&path)
267            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
268
269        let entry: CacheEntry<V> = serde_json::from_str(&contents)
270            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
271
272        Ok(Some(entry.data))
273    }
274
275    fn set(&self, key: &str, value: &V) -> Result<()> {
276        if !self.is_enabled() {
277            return Ok(());
278        }
279
280        let Some(path) = self.cache_path(key) else {
281            return Ok(());
282        };
283
284        // Create parent directories if needed
285        if let Some(parent) = path.parent() {
286            fs::create_dir_all(parent).with_context(|| {
287                format!("Failed to create cache directory: {}", parent.display())
288            })?;
289        }
290
291        let entry = CacheEntry::new(value);
292        let contents =
293            serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
294
295        // Atomic write: write to temp file, then rename
296        let temp_path = path.with_extension("tmp");
297        fs::write(&temp_path, contents)
298            .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
299
300        fs::rename(&temp_path, &path)
301            .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
302
303        Ok(())
304    }
305
306    fn remove(&self, key: &str) -> Result<()> {
307        if !self.is_enabled() {
308            return Ok(());
309        }
310
311        let Some(path) = self.cache_path(key) else {
312            return Ok(());
313        };
314
315        if path.exists() {
316            fs::remove_file(&path)
317                .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
318        }
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328    struct TestData {
329        value: String,
330        count: u32,
331    }
332
333    #[test]
334    fn test_cache_entry_new() {
335        let data = TestData {
336            value: "test".to_string(),
337            count: 42,
338        };
339        let entry = CacheEntry::new(data.clone());
340
341        assert_eq!(entry.data, data);
342        assert!(entry.etag.is_none());
343    }
344
345    #[test]
346    fn test_cache_entry_with_etag() {
347        let data = TestData {
348            value: "test".to_string(),
349            count: 42,
350        };
351        let etag = "abc123".to_string();
352        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
353
354        assert_eq!(entry.data, data);
355        assert_eq!(entry.etag, Some(etag));
356    }
357
358    #[test]
359    fn test_cache_entry_is_valid_within_ttl() {
360        let data = TestData {
361            value: "test".to_string(),
362            count: 42,
363        };
364        let entry = CacheEntry::new(data);
365        let ttl = Duration::hours(1);
366
367        assert!(entry.is_valid(ttl));
368    }
369
370    #[test]
371    fn test_cache_entry_is_valid_expired() {
372        let data = TestData {
373            value: "test".to_string(),
374            count: 42,
375        };
376        let mut entry = CacheEntry::new(data);
377        // Manually set cached_at to 2 hours ago
378        entry.cached_at = Utc::now() - Duration::hours(2);
379        let ttl = Duration::hours(1);
380
381        assert!(!entry.is_valid(ttl));
382    }
383
384    #[test]
385    fn test_cache_dir_path() {
386        let dir = cache_dir();
387        assert!(dir.is_some());
388        assert!(dir.unwrap().ends_with("aptu"));
389    }
390
391    #[test]
392    fn test_cache_serialization_with_etag() {
393        let data = TestData {
394            value: "test".to_string(),
395            count: 42,
396        };
397        let etag = "xyz789".to_string();
398        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
399
400        let json = serde_json::to_string(&entry).expect("serialize");
401        let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
402
403        assert_eq!(parsed.data, data);
404        assert_eq!(parsed.etag, Some(etag));
405    }
406
407    #[test]
408    fn test_file_cache_get_set() {
409        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
410        let data = TestData {
411            value: "test".to_string(),
412            count: 42,
413        };
414
415        // Set value
416        cache.set("test_key", &data).expect("set cache");
417
418        // Get value
419        let result = cache.get("test_key").expect("get cache");
420        assert!(result.is_some());
421        assert_eq!(result.unwrap(), data);
422
423        // Cleanup
424        cache.remove("test_key").ok();
425    }
426
427    #[test]
428    fn test_file_cache_get_miss() {
429        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
430
431        let result = cache.get("nonexistent").expect("get cache");
432        assert!(result.is_none());
433    }
434
435    #[test]
436    fn test_file_cache_get_stale() {
437        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
438        let data = TestData {
439            value: "stale".to_string(),
440            count: 99,
441        };
442
443        // Set value
444        cache.set("stale_key", &data).expect("set cache");
445
446        // Wait for TTL to expire
447        std::thread::sleep(std::time::Duration::from_millis(10));
448
449        // get() should return None (expired)
450        let result = cache.get("stale_key").expect("get cache");
451        assert!(result.is_none());
452
453        // get_stale() should return the value
454        let stale_result = cache.get_stale("stale_key").expect("get stale cache");
455        assert!(stale_result.is_some());
456        assert_eq!(stale_result.unwrap(), data);
457
458        // Cleanup
459        cache.remove("stale_key").ok();
460    }
461
462    #[test]
463    fn test_file_cache_remove() {
464        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
465        let data = TestData {
466            value: "remove_me".to_string(),
467            count: 1,
468        };
469
470        // Set value
471        cache.set("remove_key", &data).expect("set cache");
472
473        // Verify it exists
474        assert!(cache.get("remove_key").expect("get cache").is_some());
475
476        // Remove it
477        cache.remove("remove_key").expect("remove cache");
478
479        // Verify it's gone
480        assert!(cache.get("remove_key").expect("get cache").is_none());
481    }
482
483    #[test]
484    #[should_panic(expected = "cache key must not contain path separators")]
485    fn test_cache_key_rejects_forward_slash() {
486        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
487        let _ = cache.get("../etc/passwd");
488    }
489
490    #[test]
491    #[should_panic(expected = "cache key must not contain path separators")]
492    fn test_cache_key_rejects_backslash() {
493        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
494        let _ = cache.get("..\\windows\\system32");
495    }
496
497    #[test]
498    #[should_panic(expected = "cache key must not contain path separators")]
499    fn test_cache_key_rejects_parent_dir() {
500        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
501        let _ = cache.get("foo..bar");
502    }
503
504    #[test]
505    fn test_disabled_cache_get_returns_none() {
506        let cache: FileCacheImpl<TestData> =
507            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
508        let result = cache.get("any_key").expect("get should succeed");
509        assert!(result.is_none());
510    }
511
512    #[test]
513    fn test_disabled_cache_set_succeeds_silently() {
514        let cache: FileCacheImpl<TestData> =
515            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
516        let data = TestData {
517            value: "test".to_string(),
518            count: 42,
519        };
520        cache.set("any_key", &data).expect("set should succeed");
521    }
522
523    #[test]
524    fn test_disabled_cache_remove_succeeds_silently() {
525        let cache: FileCacheImpl<TestData> =
526            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
527        cache.remove("any_key").expect("remove should succeed");
528    }
529
530    #[test]
531    fn test_disabled_cache_get_stale_returns_none() {
532        let cache: FileCacheImpl<TestData> =
533            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
534        let result = cache
535            .get_stale("any_key")
536            .expect("get_stale should succeed");
537        assert!(result.is_none());
538    }
539}