Skip to main content

assay_registry/
cache.rs

1//! Local cache layer for packs.
2//!
3//! Provides caching with integrity verification on read (TOCTOU protection).
4//!
5//! # Cache Structure
6//!
7//! ```text
8//! ~/.assay/cache/packs/{name}/{version}/
9//!   pack.yaml        # Pack content
10//!   metadata.json    # Cache metadata
11//!   signature.json   # DSSE envelope (optional)
12//! ```
13
14use std::path::{Path, PathBuf};
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18#[cfg(test)]
19use tokio::fs;
20
21#[cfg(test)]
22use crate::error::RegistryError;
23use crate::error::RegistryResult;
24#[cfg(test)]
25use crate::types::PackHeaders;
26use crate::types::{DsseEnvelope, FetchResult};
27#[cfg(test)]
28use crate::verify::compute_digest;
29
30#[path = "cache_next/mod.rs"]
31mod cache_next;
32
33/// Default cache TTL (24 hours).
34const DEFAULT_TTL_SECS: i64 = 24 * 60 * 60;
35
36/// Cache metadata stored alongside pack content.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CacheMeta {
39    /// When the pack was fetched.
40    pub fetched_at: DateTime<Utc>,
41
42    /// Content digest (sha256:...).
43    pub digest: String,
44
45    /// ETag for conditional requests.
46    #[serde(default)]
47    pub etag: Option<String>,
48
49    /// When the cache entry expires.
50    pub expires_at: DateTime<Utc>,
51
52    /// Key ID used to sign (if signed).
53    #[serde(default)]
54    pub key_id: Option<String>,
55
56    /// Registry URL this was fetched from.
57    #[serde(default)]
58    pub registry_url: Option<String>,
59}
60
61/// Pack cache for storing and retrieving packs locally.
62#[derive(Debug, Clone)]
63pub struct PackCache {
64    /// Base cache directory.
65    cache_dir: PathBuf,
66}
67
68/// Cached pack entry.
69#[derive(Debug, Clone)]
70pub struct CacheEntry {
71    /// Pack content.
72    pub content: String,
73
74    /// Cache metadata.
75    pub metadata: CacheMeta,
76
77    /// DSSE envelope (if signed).
78    pub signature: Option<DsseEnvelope>,
79}
80
81impl PackCache {
82    /// Create a new cache with default location.
83    ///
84    /// Default: `~/.assay/cache/packs`
85    pub fn new() -> RegistryResult<Self> {
86        let cache_dir = cache_next::io::default_cache_dir_impl()?;
87        Ok(Self { cache_dir })
88    }
89
90    /// Create a cache with a custom directory.
91    pub fn with_dir(cache_dir: impl Into<PathBuf>) -> Self {
92        Self {
93            cache_dir: cache_dir.into(),
94        }
95    }
96
97    /// Get the cache directory.
98    pub fn cache_dir(&self) -> &Path {
99        &self.cache_dir
100    }
101
102    /// Get the path for a pack's cache directory.
103    fn pack_dir(&self, name: &str, version: &str) -> PathBuf {
104        cache_next::keys::pack_dir_impl(&self.cache_dir, name, version)
105    }
106
107    /// Get a cached pack, verifying integrity on read.
108    ///
109    /// Returns `None` if not cached or expired.
110    /// Returns `Err` if integrity verification fails (caller should evict and re-fetch).
111    pub async fn get(&self, name: &str, version: &str) -> RegistryResult<Option<CacheEntry>> {
112        cache_next::read::get_impl(self, name, version).await
113    }
114
115    /// Store a pack in the cache.
116    pub async fn put(
117        &self,
118        name: &str,
119        version: &str,
120        result: &FetchResult,
121        registry_url: Option<&str>,
122    ) -> RegistryResult<()> {
123        cache_next::put::put_impl(self, name, version, result, registry_url).await
124    }
125
126    /// Get cached metadata without loading content.
127    pub async fn get_metadata(&self, name: &str, version: &str) -> Option<CacheMeta> {
128        cache_next::read::get_metadata_impl(self, name, version).await
129    }
130
131    /// Get the ETag for conditional requests.
132    pub async fn get_etag(&self, name: &str, version: &str) -> Option<String> {
133        self.get_metadata(name, version).await.and_then(|m| m.etag)
134    }
135
136    /// Check if a pack is cached and not expired.
137    pub async fn is_cached(&self, name: &str, version: &str) -> bool {
138        match self.get_metadata(name, version).await {
139            Some(meta) => meta.expires_at >= Utc::now(),
140            None => false,
141        }
142    }
143
144    /// Evict a pack from the cache.
145    pub async fn evict(&self, name: &str, version: &str) -> RegistryResult<()> {
146        cache_next::evict::evict_impl(self, name, version).await
147    }
148
149    /// Clear all cached packs.
150    pub async fn clear(&self) -> RegistryResult<()> {
151        cache_next::evict::clear_impl(self).await
152    }
153
154    /// List all cached packs.
155    pub async fn list(&self) -> RegistryResult<Vec<(String, String, CacheMeta)>> {
156        cache_next::read::list_impl(self).await
157    }
158}
159
160impl Default for PackCache {
161    fn default() -> Self {
162        Self::new().unwrap_or_else(|_| Self::with_dir("/tmp/assay-cache/packs"))
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use base64::Engine;
170    use tempfile::TempDir;
171
172    fn create_test_cache() -> (PackCache, TempDir) {
173        let temp_dir = TempDir::new().unwrap();
174        let cache = PackCache::with_dir(temp_dir.path().join("cache"));
175        (cache, temp_dir)
176    }
177
178    fn create_fetch_result(content: &str) -> FetchResult {
179        FetchResult {
180            content: content.to_string(),
181            headers: PackHeaders {
182                digest: Some(compute_digest(content)),
183                signature: None,
184                key_id: None,
185                etag: Some("\"abc123\"".to_string()),
186                cache_control: Some("max-age=3600".to_string()),
187                content_length: Some(content.len() as u64),
188            },
189            computed_digest: compute_digest(content),
190        }
191    }
192
193    #[tokio::test]
194    async fn test_cache_roundtrip() {
195        let (cache, _temp_dir) = create_test_cache();
196        let content = "name: test\nversion: 1.0.0";
197        let result = create_fetch_result(content);
198
199        // Put
200        cache
201            .put("test-pack", "1.0.0", &result, None)
202            .await
203            .unwrap();
204
205        // Get
206        let entry = cache.get("test-pack", "1.0.0").await.unwrap().unwrap();
207        assert_eq!(entry.content, content);
208        assert_eq!(entry.metadata.digest, compute_digest(content));
209    }
210
211    #[tokio::test]
212    async fn test_cache_miss() {
213        let (cache, _temp_dir) = create_test_cache();
214
215        let result = cache.get("nonexistent", "1.0.0").await.unwrap();
216        assert!(result.is_none());
217    }
218
219    #[tokio::test]
220    async fn test_cache_integrity_failure() {
221        let (cache, _temp_dir) = create_test_cache();
222        let content = "name: test\nversion: 1.0.0";
223        let result = create_fetch_result(content);
224
225        // Put
226        cache
227            .put("test-pack", "1.0.0", &result, None)
228            .await
229            .unwrap();
230
231        // Corrupt the cached file
232        let pack_path = cache.pack_dir("test-pack", "1.0.0").join("pack.yaml");
233        fs::write(&pack_path, "corrupted content").await.unwrap();
234
235        // Get should fail integrity check
236        let err = cache.get("test-pack", "1.0.0").await.unwrap_err();
237        assert!(matches!(err, RegistryError::DigestMismatch { .. }));
238    }
239
240    #[tokio::test]
241    async fn test_cache_expiry() {
242        let (cache, _temp_dir) = create_test_cache();
243        let content = "name: test\nversion: 1.0.0";
244        let result = FetchResult {
245            content: content.to_string(),
246            headers: PackHeaders {
247                digest: Some(compute_digest(content)),
248                signature: None,
249                key_id: None,
250                etag: None,
251                cache_control: Some("max-age=0".to_string()), // Expire immediately
252                content_length: None,
253            },
254            computed_digest: compute_digest(content),
255        };
256
257        // Put
258        cache
259            .put("test-pack", "1.0.0", &result, None)
260            .await
261            .unwrap();
262
263        // Wait a moment
264        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
265
266        // Get should return None (expired)
267        let entry = cache.get("test-pack", "1.0.0").await.unwrap();
268        assert!(entry.is_none());
269    }
270
271    #[tokio::test]
272    async fn test_cache_evict() {
273        let (cache, _temp_dir) = create_test_cache();
274        let content = "name: test\nversion: 1.0.0";
275        let result = create_fetch_result(content);
276
277        // Put
278        cache
279            .put("test-pack", "1.0.0", &result, None)
280            .await
281            .unwrap();
282        assert!(cache.is_cached("test-pack", "1.0.0").await);
283
284        // Evict
285        cache.evict("test-pack", "1.0.0").await.unwrap();
286        assert!(!cache.is_cached("test-pack", "1.0.0").await);
287    }
288
289    #[tokio::test]
290    async fn test_cache_clear() {
291        let (cache, _temp_dir) = create_test_cache();
292        let content = "name: test\nversion: 1.0.0";
293        let result = create_fetch_result(content);
294
295        // Put multiple packs
296        cache.put("pack1", "1.0.0", &result, None).await.unwrap();
297        cache.put("pack2", "1.0.0", &result, None).await.unwrap();
298
299        // Clear
300        cache.clear().await.unwrap();
301
302        // Both should be gone
303        assert!(!cache.is_cached("pack1", "1.0.0").await);
304        assert!(!cache.is_cached("pack2", "1.0.0").await);
305    }
306
307    #[tokio::test]
308    async fn test_cache_list() {
309        let (cache, _temp_dir) = create_test_cache();
310        let content = "name: test\nversion: 1.0.0";
311        let result = create_fetch_result(content);
312
313        // Put multiple packs
314        cache.put("pack1", "1.0.0", &result, None).await.unwrap();
315        cache.put("pack1", "2.0.0", &result, None).await.unwrap();
316        cache.put("pack2", "1.0.0", &result, None).await.unwrap();
317
318        // List
319        let entries = cache.list().await.unwrap();
320        assert_eq!(entries.len(), 3);
321    }
322
323    #[tokio::test]
324    async fn test_get_etag() {
325        let (cache, _temp_dir) = create_test_cache();
326        let content = "name: test\nversion: 1.0.0";
327        let result = create_fetch_result(content);
328
329        // Put
330        cache
331            .put("test-pack", "1.0.0", &result, None)
332            .await
333            .unwrap();
334
335        // Get ETag
336        let etag = cache.get_etag("test-pack", "1.0.0").await;
337        assert_eq!(etag, Some("\"abc123\"".to_string()));
338    }
339
340    #[tokio::test]
341    async fn test_parse_cache_control() {
342        let headers = PackHeaders {
343            digest: None,
344            signature: None,
345            key_id: None,
346            etag: None,
347            cache_control: Some("max-age=7200, public".to_string()),
348            content_length: None,
349        };
350
351        let expires =
352            cache_next::policy::parse_cache_control_expiry_impl(&headers, DEFAULT_TTL_SECS);
353        let now = Utc::now();
354
355        // Should be approximately 2 hours in the future
356        let diff = expires - now;
357        assert!(diff.num_seconds() >= 7190 && diff.num_seconds() <= 7210);
358    }
359
360    #[tokio::test]
361    async fn test_default_ttl() {
362        let headers = PackHeaders {
363            digest: None,
364            signature: None,
365            key_id: None,
366            etag: None,
367            cache_control: None, // No Cache-Control
368            content_length: None,
369        };
370
371        let expires =
372            cache_next::policy::parse_cache_control_expiry_impl(&headers, DEFAULT_TTL_SECS);
373        let now = Utc::now();
374
375        // Should be approximately 24 hours in the future
376        let diff = expires - now;
377        assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25);
378    }
379
380    #[tokio::test]
381    async fn test_cache_with_signature() {
382        let (cache, _temp_dir) = create_test_cache();
383        let content = "name: test\nversion: 1.0.0";
384
385        // Create a mock DSSE envelope
386        let envelope = DsseEnvelope {
387            payload_type: "application/vnd.assay.pack+yaml;v=1".to_string(),
388            payload: base64::engine::general_purpose::STANDARD.encode(content),
389            signatures: vec![],
390        };
391        let envelope_json = serde_json::to_vec(&envelope).unwrap();
392        let envelope_b64 = base64::engine::general_purpose::STANDARD.encode(&envelope_json);
393
394        let result = FetchResult {
395            content: content.to_string(),
396            headers: PackHeaders {
397                digest: Some(compute_digest(content)),
398                signature: Some(envelope_b64),
399                key_id: Some("sha256:test-key".to_string()),
400                etag: None,
401                cache_control: Some("max-age=3600".to_string()),
402                content_length: None,
403            },
404            computed_digest: compute_digest(content),
405        };
406
407        // Put
408        cache
409            .put("test-pack", "1.0.0", &result, None)
410            .await
411            .unwrap();
412
413        // Get
414        let entry = cache.get("test-pack", "1.0.0").await.unwrap().unwrap();
415        assert!(entry.signature.is_some());
416        assert_eq!(
417            entry.signature.unwrap().payload_type,
418            "application/vnd.assay.pack+yaml;v=1"
419        );
420    }
421
422    // ==================== Cache Robustness Tests (SPEC §7.2) ====================
423
424    #[tokio::test]
425    async fn test_pack_yaml_corrupt_evict_refetch() {
426        // SPEC §7.2: Corrupted cache entry should be detected and evictable
427        let (cache, _temp_dir) = create_test_cache();
428        let content = "name: test\nversion: \"1.0.0\"";
429        let result = create_fetch_result(content);
430
431        // Put valid content
432        cache
433            .put("test-pack", "1.0.0", &result, None)
434            .await
435            .unwrap();
436
437        // Verify it works
438        let entry = cache.get("test-pack", "1.0.0").await.unwrap();
439        assert!(entry.is_some());
440
441        // Corrupt the cached file
442        let pack_path = cache.pack_dir("test-pack", "1.0.0").join("pack.yaml");
443        fs::write(&pack_path, "corrupted: content\nmalicious: true")
444            .await
445            .unwrap();
446
447        // Get should fail with DigestMismatch
448        let err = cache.get("test-pack", "1.0.0").await.unwrap_err();
449        assert!(
450            matches!(err, RegistryError::DigestMismatch { .. }),
451            "Should detect corruption: {:?}",
452            err
453        );
454
455        // Evict the corrupted entry
456        cache.evict("test-pack", "1.0.0").await.unwrap();
457
458        // Now cache should be empty
459        let entry = cache.get("test-pack", "1.0.0").await.unwrap();
460        assert!(entry.is_none(), "Cache should be empty after evict");
461    }
462
463    #[tokio::test]
464    async fn test_signature_json_corrupt_handling() {
465        // SPEC §7.2: Corrupted signature.json should not crash, signature becomes None
466        let (cache, _temp_dir) = create_test_cache();
467        let content = "name: test\nversion: \"1.0.0\"";
468
469        // Create with valid signature
470        let envelope = DsseEnvelope {
471            payload_type: "application/vnd.assay.pack+yaml;v=1".to_string(),
472            payload: base64::engine::general_purpose::STANDARD.encode(content),
473            signatures: vec![],
474        };
475        let envelope_json = serde_json::to_vec(&envelope).unwrap();
476        let envelope_b64 = base64::engine::general_purpose::STANDARD.encode(&envelope_json);
477
478        let result = FetchResult {
479            content: content.to_string(),
480            headers: PackHeaders {
481                digest: Some(compute_digest(content)),
482                signature: Some(envelope_b64),
483                key_id: Some("sha256:test-key".to_string()),
484                etag: None,
485                cache_control: Some("max-age=3600".to_string()),
486                content_length: None,
487            },
488            computed_digest: compute_digest(content),
489        };
490
491        cache
492            .put("test-pack", "1.0.0", &result, None)
493            .await
494            .unwrap();
495
496        // Verify signature exists
497        let entry = cache.get("test-pack", "1.0.0").await.unwrap().unwrap();
498        assert!(entry.signature.is_some());
499
500        // Corrupt the signature file
501        let sig_path = cache.pack_dir("test-pack", "1.0.0").join("signature.json");
502        fs::write(&sig_path, "this is not valid json{{{")
503            .await
504            .unwrap();
505
506        // Get should still work, but signature is None (graceful degradation)
507        let entry = cache.get("test-pack", "1.0.0").await.unwrap().unwrap();
508        assert!(
509            entry.signature.is_none(),
510            "Corrupt signature should be None, not error"
511        );
512        // Content should still be valid
513        assert_eq!(entry.content, content);
514    }
515
516    #[tokio::test]
517    async fn test_metadata_json_corrupt_handling() {
518        // SPEC §7.2: Corrupted metadata.json should return cache miss
519        let (cache, _temp_dir) = create_test_cache();
520        let content = "name: test\nversion: \"1.0.0\"";
521        let result = create_fetch_result(content);
522
523        cache
524            .put("test-pack", "1.0.0", &result, None)
525            .await
526            .unwrap();
527
528        // Corrupt the metadata file
529        let meta_path = cache.pack_dir("test-pack", "1.0.0").join("metadata.json");
530        fs::write(&meta_path, "invalid json content").await.unwrap();
531
532        // Get should fail with cache error (not crash)
533        let result = cache.get("test-pack", "1.0.0").await;
534        assert!(
535            matches!(result, Err(RegistryError::Cache { .. })),
536            "Should return cache error for corrupt metadata: {:?}",
537            result
538        );
539    }
540
541    #[tokio::test]
542    async fn test_atomic_write_prevents_partial_cache() {
543        // SPEC §7.2: Atomic writes prevent partial/corrupt cache entries
544        let (cache, _temp_dir) = create_test_cache();
545        let content = "name: test\nversion: \"1.0.0\"";
546        let result = create_fetch_result(content);
547
548        // After put, no .tmp files should exist
549        cache
550            .put("test-pack", "1.0.0", &result, None)
551            .await
552            .unwrap();
553
554        let pack_dir = cache.pack_dir("test-pack", "1.0.0");
555
556        // Check no temp files remain
557        let mut entries = fs::read_dir(&pack_dir).await.unwrap();
558        while let Some(entry) = entries.next_entry().await.unwrap() {
559            let name = entry.file_name();
560            let name_str = name.to_string_lossy();
561            assert!(
562                !name_str.ends_with(".tmp"),
563                "Temp file should not remain: {}",
564                name_str
565            );
566        }
567    }
568
569    #[tokio::test]
570    async fn test_cache_registry_url_tracking() {
571        // SPEC §7.1: Cache should track which registry pack came from
572        let (cache, _temp_dir) = create_test_cache();
573        let content = "name: test\nversion: \"1.0.0\"";
574        let result = create_fetch_result(content);
575
576        cache
577            .put(
578                "test-pack",
579                "1.0.0",
580                &result,
581                Some("https://registry.example.com/v1"),
582            )
583            .await
584            .unwrap();
585
586        let meta = cache.get_metadata("test-pack", "1.0.0").await.unwrap();
587        assert_eq!(
588            meta.registry_url,
589            Some("https://registry.example.com/v1".to_string())
590        );
591    }
592}