1use 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
33const DEFAULT_TTL_SECS: i64 = 24 * 60 * 60;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CacheMeta {
39 pub fetched_at: DateTime<Utc>,
41
42 pub digest: String,
44
45 #[serde(default)]
47 pub etag: Option<String>,
48
49 pub expires_at: DateTime<Utc>,
51
52 #[serde(default)]
54 pub key_id: Option<String>,
55
56 #[serde(default)]
58 pub registry_url: Option<String>,
59}
60
61#[derive(Debug, Clone)]
63pub struct PackCache {
64 cache_dir: PathBuf,
66}
67
68#[derive(Debug, Clone)]
70pub struct CacheEntry {
71 pub content: String,
73
74 pub metadata: CacheMeta,
76
77 pub signature: Option<DsseEnvelope>,
79}
80
81impl PackCache {
82 pub fn new() -> RegistryResult<Self> {
86 let cache_dir = cache_next::io::default_cache_dir_impl()?;
87 Ok(Self { cache_dir })
88 }
89
90 pub fn with_dir(cache_dir: impl Into<PathBuf>) -> Self {
92 Self {
93 cache_dir: cache_dir.into(),
94 }
95 }
96
97 pub fn cache_dir(&self) -> &Path {
99 &self.cache_dir
100 }
101
102 fn pack_dir(&self, name: &str, version: &str) -> PathBuf {
104 cache_next::keys::pack_dir_impl(&self.cache_dir, name, version)
105 }
106
107 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 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 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 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 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 pub async fn evict(&self, name: &str, version: &str) -> RegistryResult<()> {
146 cache_next::evict::evict_impl(self, name, version).await
147 }
148
149 pub async fn clear(&self) -> RegistryResult<()> {
151 cache_next::evict::clear_impl(self).await
152 }
153
154 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 cache
201 .put("test-pack", "1.0.0", &result, None)
202 .await
203 .unwrap();
204
205 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 cache
227 .put("test-pack", "1.0.0", &result, None)
228 .await
229 .unwrap();
230
231 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 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()), content_length: None,
253 },
254 computed_digest: compute_digest(content),
255 };
256
257 cache
259 .put("test-pack", "1.0.0", &result, None)
260 .await
261 .unwrap();
262
263 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
265
266 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 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 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 cache.put("pack1", "1.0.0", &result, None).await.unwrap();
297 cache.put("pack2", "1.0.0", &result, None).await.unwrap();
298
299 cache.clear().await.unwrap();
301
302 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 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 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 cache
331 .put("test-pack", "1.0.0", &result, None)
332 .await
333 .unwrap();
334
335 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 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, 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 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 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 cache
409 .put("test-pack", "1.0.0", &result, None)
410 .await
411 .unwrap();
412
413 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 #[tokio::test]
425 async fn test_pack_yaml_corrupt_evict_refetch() {
426 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 cache
433 .put("test-pack", "1.0.0", &result, None)
434 .await
435 .unwrap();
436
437 let entry = cache.get("test-pack", "1.0.0").await.unwrap();
439 assert!(entry.is_some());
440
441 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 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 cache.evict("test-pack", "1.0.0").await.unwrap();
457
458 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 let (cache, _temp_dir) = create_test_cache();
467 let content = "name: test\nversion: \"1.0.0\"";
468
469 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 let entry = cache.get("test-pack", "1.0.0").await.unwrap().unwrap();
498 assert!(entry.signature.is_some());
499
500 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 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 assert_eq!(entry.content, content);
514 }
515
516 #[tokio::test]
517 async fn test_metadata_json_corrupt_handling() {
518 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 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 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 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 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 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 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}