sigstore_cache/
filesystem.rs1use 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
12fn url_to_dirname(url: &str) -> String {
22 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 for byte in c.to_string().as_bytes() {
33 result.push_str(&format!("%{:02X}", byte));
34 }
35 }
36 }
37 }
38 result
39}
40
41#[derive(Debug, Serialize, Deserialize)]
43struct CacheMetadata {
44 created_at: DateTime<Utc>,
46 expires_at: DateTime<Utc>,
48}
49
50#[derive(Debug, Clone)]
89pub struct FileSystemCache {
90 cache_dir: PathBuf,
92}
93
94pub const SIGSTORE_PRODUCTION_URL: &str = "https://sigstore.dev";
96
97pub const SIGSTORE_STAGING_URL: &str = "https://sigstage.dev";
99
100impl FileSystemCache {
101 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 pub fn default_location() -> Result<Self> {
118 Self::new(default_cache_dir()?)
119 }
120
121 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 pub fn production() -> Result<Self> {
150 Self::for_instance(SIGSTORE_PRODUCTION_URL)
151 }
152
153 pub fn staging() -> Result<Self> {
157 Self::for_instance(SIGSTORE_STAGING_URL)
158 }
159
160 fn cache_path(&self, key: CacheKey) -> PathBuf {
162 self.cache_dir.join(format!("{}.cache", key.as_str()))
163 }
164
165 fn meta_path(&self, key: CacheKey) -> PathBuf {
167 self.cache_dir.join(format!("{}.meta", key.as_str()))
168 }
169
170 async fn ensure_dir(&self) -> Result<()> {
172 fs::create_dir_all(&self.cache_dir).await?;
173 Ok(())
174 }
175
176 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 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 if self.read_valid_metadata(key).await?.is_none() {
202 return Ok(None);
203 }
204
205 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 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 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 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 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 let _ = cache.clear().await;
288
289 let key = CacheKey::RekorPublicKey;
290 let value = b"test-public-key-data";
291
292 assert!(cache.get(key).await.unwrap().is_none());
294
295 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 cache.remove(key).await.unwrap();
305 assert!(cache.get(key).await.unwrap().is_none());
306
307 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 cache.set(key, value, Duration::from_secs(0)).await.unwrap();
322
323 tokio::time::sleep(Duration::from_millis(10)).await;
325 assert!(cache.get(key).await.unwrap().is_none());
326
327 let _ = std::fs::remove_dir_all(&temp_dir);
329 }
330
331 #[test]
332 fn test_url_to_dirname() {
333 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 assert_eq!(
345 url_to_dirname("https://example.com/path/to/resource"),
346 "https%3A%2F%2Fexample.com%2Fpath%2Fto%2Fresource"
347 );
348
349 assert_eq!(
351 url_to_dirname("https://localhost:8080"),
352 "https%3A%2F%2Flocalhost%3A8080"
353 );
354
355 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 assert_ne!(prod.cache_dir, staging.cache_dir);
366
367 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}