sigstore_cache/
filesystem.rs

1//! File system based cache implementation
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use tokio::fs;
9
10use crate::{default_cache_dir, CacheAdapter, CacheKey, Result};
11
12/// Convert a URL to a safe directory name
13///
14/// This URL-encodes the URL to create a safe filesystem path, similar to
15/// how sigstore-python handles TUF repository URLs.
16///
17/// # Example
18/// ```ignore
19/// url_to_dirname("https://sigstore.dev") // -> "https%3A%2F%2Fsigstore.dev"
20/// ```
21fn url_to_dirname(url: &str) -> String {
22    // URL-encode the URL to make it safe for use as a directory name
23    // We encode everything except alphanumerics and some safe chars
24    let mut result = String::with_capacity(url.len() * 3);
25    for c in url.chars() {
26        match c {
27            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => {
28                result.push(c);
29            }
30            _ => {
31                // Percent-encode other characters
32                for byte in c.to_string().as_bytes() {
33                    result.push_str(&format!("%{:02X}", byte));
34                }
35            }
36        }
37    }
38    result
39}
40
41/// Metadata stored alongside cached values
42#[derive(Debug, Serialize, Deserialize)]
43struct CacheMetadata {
44    /// When the cache entry was created
45    created_at: DateTime<Utc>,
46    /// When the cache entry expires
47    expires_at: DateTime<Utc>,
48}
49
50/// File system based cache
51///
52/// Stores cached values as files on disk. Each cache key maps to a file,
53/// with a companion metadata file tracking expiration.
54///
55/// # Directory Structure
56///
57/// ```text
58/// cache_dir/
59/// ├── rekor_public_key.cache
60/// ├── rekor_public_key.meta
61/// ├── fulcio_trust_bundle.cache
62/// ├── fulcio_trust_bundle.meta
63/// └── ...
64/// ```
65///
66/// # Example
67///
68/// ```no_run
69/// use sigstore_cache::{FileSystemCache, CacheAdapter, CacheKey};
70/// use std::time::Duration;
71///
72/// # async fn example() -> Result<(), sigstore_cache::Error> {
73/// // Use default location
74/// let cache = FileSystemCache::default_location()?;
75///
76/// // Or specify custom directory
77/// let cache = FileSystemCache::new("/tmp/my-sigstore-cache")?;
78///
79/// // Cache a value
80/// cache.set(
81///     CacheKey::RekorPublicKey,
82///     b"public-key-data",
83///     Duration::from_secs(86400)
84/// ).await?;
85/// # Ok(())
86/// # }
87/// ```
88#[derive(Debug, Clone)]
89pub struct FileSystemCache {
90    /// Base directory for cache files
91    cache_dir: PathBuf,
92}
93
94/// Sigstore production instance base URL
95pub const SIGSTORE_PRODUCTION_URL: &str = "https://sigstore.dev";
96
97/// Sigstore staging instance base URL
98pub const SIGSTORE_STAGING_URL: &str = "https://sigstage.dev";
99
100impl FileSystemCache {
101    /// Create a new file system cache at the specified directory
102    ///
103    /// The directory will be created if it doesn't exist when writing.
104    pub fn new(cache_dir: impl AsRef<Path>) -> Result<Self> {
105        Ok(Self {
106            cache_dir: cache_dir.as_ref().to_path_buf(),
107        })
108    }
109
110    /// Create a cache at the default platform-specific location
111    ///
112    /// See [`default_cache_dir`] for the exact locations.
113    ///
114    /// **Warning**: This cache is not namespaced by instance URL. If you use
115    /// multiple Sigstore instances (e.g., production and staging), use
116    /// [`FileSystemCache::for_instance`] instead to avoid cache collisions.
117    pub fn default_location() -> Result<Self> {
118        Self::new(default_cache_dir()?)
119    }
120
121    /// Create a cache namespaced to a specific Sigstore instance URL
122    ///
123    /// This creates a subdirectory based on the URL, preventing cache collisions
124    /// when using multiple Sigstore instances (e.g., production vs staging).
125    ///
126    /// # Example
127    ///
128    /// ```no_run
129    /// use sigstore_cache::FileSystemCache;
130    ///
131    /// # fn example() -> Result<(), sigstore_cache::Error> {
132    /// // Cache for production instance
133    /// let prod_cache = FileSystemCache::for_instance("https://sigstore.dev")?;
134    ///
135    /// // Cache for staging instance (separate directory)
136    /// let staging_cache = FileSystemCache::for_instance("https://sigstage.dev")?;
137    /// # Ok(())
138    /// # }
139    /// ```
140    pub fn for_instance(base_url: &str) -> Result<Self> {
141        let namespace = url_to_dirname(base_url);
142        let path = default_cache_dir()?.join(namespace);
143        Self::new(path)
144    }
145
146    /// Create a cache for the Sigstore production instance
147    ///
148    /// Equivalent to `FileSystemCache::for_instance("https://sigstore.dev")`.
149    pub fn production() -> Result<Self> {
150        Self::for_instance(SIGSTORE_PRODUCTION_URL)
151    }
152
153    /// Create a cache for the Sigstore staging instance
154    ///
155    /// Equivalent to `FileSystemCache::for_instance("https://sigstage.dev")`.
156    pub fn staging() -> Result<Self> {
157        Self::for_instance(SIGSTORE_STAGING_URL)
158    }
159
160    /// Get the path for a cache file
161    fn cache_path(&self, key: CacheKey) -> PathBuf {
162        self.cache_dir.join(format!("{}.cache", key.as_str()))
163    }
164
165    /// Get the path for a metadata file
166    fn meta_path(&self, key: CacheKey) -> PathBuf {
167        self.cache_dir.join(format!("{}.meta", key.as_str()))
168    }
169
170    /// Ensure the cache directory exists
171    async fn ensure_dir(&self) -> Result<()> {
172        fs::create_dir_all(&self.cache_dir).await?;
173        Ok(())
174    }
175
176    /// Read and validate metadata, returning None if expired or missing
177    async fn read_valid_metadata(&self, key: CacheKey) -> Result<Option<CacheMetadata>> {
178        let meta_path = self.meta_path(key);
179
180        match fs::read_to_string(&meta_path).await {
181            Ok(content) => {
182                let metadata: CacheMetadata = serde_json::from_str(&content)?;
183                if Utc::now() < metadata.expires_at {
184                    Ok(Some(metadata))
185                } else {
186                    // Expired - clean up
187                    let _ = self.remove(key).await;
188                    Ok(None)
189                }
190            }
191            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
192            Err(e) => Err(e.into()),
193        }
194    }
195}
196
197impl CacheAdapter for FileSystemCache {
198    fn get(&self, key: CacheKey) -> crate::CacheGetFuture<'_> {
199        Box::pin(async move {
200            // Check if metadata exists and is valid
201            if self.read_valid_metadata(key).await?.is_none() {
202                return Ok(None);
203            }
204
205            // Read the cached data
206            let cache_path = self.cache_path(key);
207            match fs::read(&cache_path).await {
208                Ok(data) => Ok(Some(data)),
209                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
210                Err(e) => Err(e.into()),
211            }
212        })
213    }
214
215    fn set(&self, key: CacheKey, value: &[u8], ttl: Duration) -> crate::CacheOpFuture<'_> {
216        let value = value.to_vec();
217        Box::pin(async move {
218            self.ensure_dir().await?;
219
220            let now = Utc::now();
221            let metadata = CacheMetadata {
222                created_at: now,
223                expires_at: now
224                    + chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::days(1)),
225            };
226
227            // Write metadata first (atomic-ish - if this fails, cache entry is invalid)
228            let meta_path = self.meta_path(key);
229            let meta_json = serde_json::to_string_pretty(&metadata)?;
230            fs::write(&meta_path, meta_json).await?;
231
232            // Write the actual data
233            let cache_path = self.cache_path(key);
234            fs::write(&cache_path, &value).await?;
235
236            Ok(())
237        })
238    }
239
240    fn remove(&self, key: CacheKey) -> crate::CacheOpFuture<'_> {
241        Box::pin(async move {
242            let cache_path = self.cache_path(key);
243            let meta_path = self.meta_path(key);
244
245            // Ignore errors - files might not exist
246            let _ = fs::remove_file(&cache_path).await;
247            let _ = fs::remove_file(&meta_path).await;
248
249            Ok(())
250        })
251    }
252
253    fn clear(&self) -> crate::CacheOpFuture<'_> {
254        Box::pin(async move {
255            // Remove all .cache and .meta files in the cache directory
256            let mut entries = match fs::read_dir(&self.cache_dir).await {
257                Ok(entries) => entries,
258                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
259                Err(e) => return Err(e.into()),
260            };
261
262            while let Some(entry) = entries.next_entry().await? {
263                let path = entry.path();
264                if let Some(ext) = path.extension() {
265                    if ext == "cache" || ext == "meta" {
266                        let _ = fs::remove_file(&path).await;
267                    }
268                }
269            }
270
271            Ok(())
272        })
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::time::Duration;
280
281    #[tokio::test]
282    async fn test_filesystem_cache_roundtrip() {
283        let temp_dir = std::env::temp_dir().join("sigstore-cache-test");
284        let cache = FileSystemCache::new(&temp_dir).unwrap();
285
286        // Clean up from previous runs
287        let _ = cache.clear().await;
288
289        let key = CacheKey::RekorPublicKey;
290        let value = b"test-public-key-data";
291
292        // Initially empty
293        assert!(cache.get(key).await.unwrap().is_none());
294
295        // Set and get
296        cache
297            .set(key, value, Duration::from_secs(3600))
298            .await
299            .unwrap();
300        let retrieved = cache.get(key).await.unwrap().unwrap();
301        assert_eq!(retrieved, value);
302
303        // Remove
304        cache.remove(key).await.unwrap();
305        assert!(cache.get(key).await.unwrap().is_none());
306
307        // Clean up
308        let _ = std::fs::remove_dir_all(&temp_dir);
309    }
310
311    #[tokio::test]
312    async fn test_filesystem_cache_expiration() {
313        let temp_dir = std::env::temp_dir().join("sigstore-cache-expiry-test");
314        let cache = FileSystemCache::new(&temp_dir).unwrap();
315        let _ = cache.clear().await;
316
317        let key = CacheKey::FulcioConfiguration;
318        let value = b"test-config";
319
320        // Set with very short TTL (already expired)
321        cache.set(key, value, Duration::from_secs(0)).await.unwrap();
322
323        // Should be expired
324        tokio::time::sleep(Duration::from_millis(10)).await;
325        assert!(cache.get(key).await.unwrap().is_none());
326
327        // Clean up
328        let _ = std::fs::remove_dir_all(&temp_dir);
329    }
330
331    #[test]
332    fn test_url_to_dirname() {
333        // Basic URL encoding
334        assert_eq!(
335            url_to_dirname("https://sigstore.dev"),
336            "https%3A%2F%2Fsigstore.dev"
337        );
338        assert_eq!(
339            url_to_dirname("https://sigstage.dev"),
340            "https%3A%2F%2Fsigstage.dev"
341        );
342
343        // URLs with paths
344        assert_eq!(
345            url_to_dirname("https://example.com/path/to/resource"),
346            "https%3A%2F%2Fexample.com%2Fpath%2Fto%2Fresource"
347        );
348
349        // URLs with ports
350        assert_eq!(
351            url_to_dirname("https://localhost:8080"),
352            "https%3A%2F%2Flocalhost%3A8080"
353        );
354
355        // Safe characters should not be encoded
356        assert_eq!(url_to_dirname("abc-123_test.txt"), "abc-123_test.txt");
357    }
358
359    #[test]
360    fn test_production_and_staging_paths_differ() {
361        let prod = FileSystemCache::production().unwrap();
362        let staging = FileSystemCache::staging().unwrap();
363
364        // Production and staging should have different cache directories
365        assert_ne!(prod.cache_dir, staging.cache_dir);
366
367        // Both should contain URL-encoded paths
368        assert!(prod
369            .cache_dir
370            .to_string_lossy()
371            .contains("https%3A%2F%2Fsigstore.dev"));
372        assert!(staging
373            .cache_dir
374            .to_string_lossy()
375            .contains("https%3A%2F%2Fsigstage.dev"));
376    }
377}