Skip to main content

assay_registry/
resolver.rs

1//! Pack resolution.
2//!
3//! Resolves pack references to content with the following priority:
4//! 1. Local file (if path exists)
5//! 2. Bundled pack (compiled into binary)
6//! 3. Cache (if valid and not expired)
7//! 4. Registry (remote fetch)
8//! 5. BYOS (Bring Your Own Storage)
9
10use std::path::Path;
11
12use tokio::fs;
13use tracing::{debug, info, warn};
14
15use crate::cache::PackCache;
16use crate::client::RegistryClient;
17use crate::error::{RegistryError, RegistryResult};
18use crate::reference::PackRef;
19use crate::trust::TrustStore;
20use crate::types::RegistryConfig;
21use crate::verify::{compute_digest, verify_pack, VerifyOptions, VerifyResult};
22
23/// Resolved pack content.
24#[derive(Debug, Clone)]
25pub struct ResolvedPack {
26    /// Pack YAML content.
27    pub content: String,
28
29    /// Where the pack was resolved from.
30    pub source: ResolveSource,
31
32    /// Content digest.
33    pub digest: String,
34
35    /// Verification result (if verified).
36    pub verification: Option<VerifyResult>,
37}
38
39/// Source of a resolved pack.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ResolveSource {
42    /// Local file.
43    Local(String),
44
45    /// Bundled with the binary.
46    Bundled(String),
47
48    /// From local cache.
49    Cache,
50
51    /// Fetched from registry.
52    Registry(String),
53
54    /// Fetched from BYOS.
55    Byos(String),
56}
57
58impl std::fmt::Display for ResolveSource {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Local(path) => write!(f, "local:{}", path),
62            Self::Bundled(name) => write!(f, "bundled:{}", name),
63            Self::Cache => write!(f, "cache"),
64            Self::Registry(url) => write!(f, "registry:{}", url),
65            Self::Byos(url) => write!(f, "byos:{}", url),
66        }
67    }
68}
69
70/// Pack resolver configuration.
71#[derive(Debug, Clone)]
72pub struct ResolverConfig {
73    /// Registry configuration.
74    pub registry: RegistryConfig,
75
76    /// Skip cache lookup.
77    pub no_cache: bool,
78
79    /// Allow unsigned packs.
80    pub allow_unsigned: bool,
81
82    /// Directory containing bundled packs.
83    pub bundled_packs_dir: Option<String>,
84}
85
86impl Default for ResolverConfig {
87    fn default() -> Self {
88        Self {
89            registry: RegistryConfig::from_env(),
90            no_cache: false,
91            allow_unsigned: false,
92            bundled_packs_dir: None,
93        }
94    }
95}
96
97impl ResolverConfig {
98    /// Skip cache.
99    pub fn no_cache(mut self) -> Self {
100        self.no_cache = true;
101        self
102    }
103
104    /// Allow unsigned packs.
105    pub fn allow_unsigned(mut self) -> Self {
106        self.allow_unsigned = true;
107        self
108    }
109
110    /// Set bundled packs directory.
111    pub fn with_bundled_dir(mut self, dir: impl Into<String>) -> Self {
112        self.bundled_packs_dir = Some(dir.into());
113        self
114    }
115}
116
117/// Pack resolver.
118pub struct PackResolver {
119    /// Registry client.
120    client: RegistryClient,
121
122    /// Local cache.
123    cache: PackCache,
124
125    /// Trust store for signature verification.
126    trust_store: TrustStore,
127
128    /// Configuration.
129    config: ResolverConfig,
130}
131
132impl PackResolver {
133    /// Create a new resolver with default configuration.
134    pub fn new() -> RegistryResult<Self> {
135        Self::with_config(ResolverConfig::default())
136    }
137
138    /// Create a resolver with custom configuration.
139    pub fn with_config(config: ResolverConfig) -> RegistryResult<Self> {
140        let client = RegistryClient::new(config.registry.clone())?;
141        let cache = PackCache::new()?;
142        let trust_store = TrustStore::from_production_roots()?;
143
144        Ok(Self {
145            client,
146            cache,
147            trust_store,
148            config,
149        })
150    }
151
152    /// Create a resolver for testing with custom components.
153    pub fn with_components(
154        client: RegistryClient,
155        cache: PackCache,
156        trust_store: TrustStore,
157        config: ResolverConfig,
158    ) -> Self {
159        Self {
160            client,
161            cache,
162            trust_store,
163            config,
164        }
165    }
166
167    /// Resolve a pack reference to content.
168    pub async fn resolve(&self, reference: &str) -> RegistryResult<ResolvedPack> {
169        let pack_ref = PackRef::parse(reference)?;
170        self.resolve_ref(&pack_ref).await
171    }
172
173    /// Resolve a parsed pack reference.
174    pub async fn resolve_ref(&self, pack_ref: &PackRef) -> RegistryResult<ResolvedPack> {
175        match pack_ref {
176            PackRef::Local(path) => self.resolve_local(path).await,
177            PackRef::Bundled(name) => self.resolve_bundled(name).await,
178            PackRef::Registry {
179                name,
180                version,
181                pinned_digest,
182            } => {
183                self.resolve_registry(name, version, pinned_digest.as_deref())
184                    .await
185            }
186            PackRef::Byos(url) => self.resolve_byos(url).await,
187        }
188    }
189
190    /// Resolve a local file.
191    async fn resolve_local(&self, path: &Path) -> RegistryResult<ResolvedPack> {
192        debug!(path = %path.display(), "resolving local file");
193
194        if !path.exists() {
195            return Err(RegistryError::NotFound {
196                name: path.display().to_string(),
197                version: "local".to_string(),
198            });
199        }
200
201        let content = fs::read_to_string(path)
202            .await
203            .map_err(|e| RegistryError::Cache {
204                message: format!("failed to read local file: {}", e),
205            })?;
206
207        let digest = compute_digest(&content);
208
209        info!(path = %path.display(), digest = %digest, "resolved local pack");
210
211        Ok(ResolvedPack {
212            content,
213            source: ResolveSource::Local(path.display().to_string()),
214            digest,
215            verification: None, // Local files are not verified
216        })
217    }
218
219    /// Resolve a bundled pack.
220    async fn resolve_bundled(&self, name: &str) -> RegistryResult<ResolvedPack> {
221        debug!(name, "resolving bundled pack");
222
223        // Check configured bundled packs directory
224        if let Some(dir) = &self.config.bundled_packs_dir {
225            let pack_path = Path::new(dir).join(format!("{}.yaml", name));
226            if pack_path.exists() {
227                let content =
228                    fs::read_to_string(&pack_path)
229                        .await
230                        .map_err(|e| RegistryError::Cache {
231                            message: format!("failed to read bundled pack: {}", e),
232                        })?;
233
234                let digest = compute_digest(&content);
235                info!(name, digest = %digest, "resolved bundled pack");
236
237                return Ok(ResolvedPack {
238                    content,
239                    source: ResolveSource::Bundled(name.to_string()),
240                    digest,
241                    verification: None,
242                });
243            }
244        }
245
246        // Look for bundled packs in standard locations
247        let standard_paths = [
248            format!("packs/open/{}.yaml", name),
249            format!("packs/{}.yaml", name),
250        ];
251
252        for relative_path in &standard_paths {
253            let path = Path::new(relative_path);
254            if path.exists() {
255                let content = fs::read_to_string(path)
256                    .await
257                    .map_err(|e| RegistryError::Cache {
258                        message: format!("failed to read bundled pack: {}", e),
259                    })?;
260
261                let digest = compute_digest(&content);
262                info!(name, path = %path.display(), digest = %digest, "resolved bundled pack");
263
264                return Ok(ResolvedPack {
265                    content,
266                    source: ResolveSource::Bundled(name.to_string()),
267                    digest,
268                    verification: None,
269                });
270            }
271        }
272
273        Err(RegistryError::NotFound {
274            name: name.to_string(),
275            version: "bundled".to_string(),
276        })
277    }
278
279    /// Resolve a registry pack.
280    async fn resolve_registry(
281        &self,
282        name: &str,
283        version: &str,
284        pinned_digest: Option<&str>,
285    ) -> RegistryResult<ResolvedPack> {
286        debug!(name, version, pinned_digest, "resolving registry pack");
287
288        // 1. Check cache first (unless --no-cache)
289        if !self.config.no_cache {
290            if let Some(cached) = self.try_cache(name, version, pinned_digest).await? {
291                return Ok(cached);
292            }
293        }
294
295        // 2. Fetch from registry
296        let etag = if self.config.no_cache {
297            None
298        } else {
299            self.cache.get_etag(name, version).await
300        };
301
302        let result = self
303            .client
304            .fetch_pack(name, version, etag.as_deref())
305            .await?;
306
307        let fetch_result =
308            match result {
309                Some(r) => r,
310                None => {
311                    // 304 Not Modified - use cached version
312                    let cached_entry = self.cache.get(name, version).await?.ok_or_else(|| {
313                        RegistryError::Cache {
314                            message: "304 response but no cached entry".to_string(),
315                        }
316                    })?;
317
318                    return Ok(ResolvedPack {
319                        content: cached_entry.content,
320                        source: ResolveSource::Cache,
321                        digest: cached_entry.metadata.digest.clone(),
322                        verification: None,
323                    });
324                }
325            };
326
327        // 3. Verify digest if pinned
328        if let Some(expected_digest) = pinned_digest {
329            if fetch_result.computed_digest != expected_digest {
330                return Err(RegistryError::DigestMismatch {
331                    name: name.to_string(),
332                    version: version.to_string(),
333                    expected: expected_digest.to_string(),
334                    actual: fetch_result.computed_digest.clone(),
335                });
336            }
337        }
338
339        // 4. Verify signature
340        let verify_options = VerifyOptions {
341            allow_unsigned: self.config.allow_unsigned,
342            skip_signature: false,
343        };
344
345        let verification = match verify_pack(&fetch_result, &self.trust_store, &verify_options) {
346            Ok(v) => Some(v),
347            Err(e) => {
348                // If unsigned and allowed, continue
349                if self.config.allow_unsigned {
350                    warn!(name, version, error = %e, "pack verification failed, but unsigned allowed");
351                    None
352                } else {
353                    return Err(e);
354                }
355            }
356        };
357
358        // 5. Cache the result
359        if !self.config.no_cache {
360            if let Err(e) = self
361                .cache
362                .put(name, version, &fetch_result, Some(self.client.base_url()))
363                .await
364            {
365                warn!(name, version, error = %e, "failed to cache pack");
366            }
367        }
368
369        let digest = fetch_result.computed_digest.clone();
370        info!(name, version, digest = %digest, "resolved registry pack");
371
372        Ok(ResolvedPack {
373            content: fetch_result.content,
374            source: ResolveSource::Registry(self.client.base_url().to_string()),
375            digest,
376            verification,
377        })
378    }
379
380    /// Try to get pack from cache.
381    async fn try_cache(
382        &self,
383        name: &str,
384        version: &str,
385        pinned_digest: Option<&str>,
386    ) -> RegistryResult<Option<ResolvedPack>> {
387        match self.cache.get(name, version).await {
388            Ok(Some(entry)) => {
389                // Check pinned digest if provided
390                if let Some(expected) = pinned_digest {
391                    if entry.metadata.digest != expected {
392                        debug!(
393                            name,
394                            version,
395                            expected,
396                            actual = %entry.metadata.digest,
397                            "cached digest does not match pinned, evicting"
398                        );
399                        self.cache.evict(name, version).await?;
400                        return Ok(None);
401                    }
402                }
403
404                info!(name, version, "using cached pack");
405                Ok(Some(ResolvedPack {
406                    content: entry.content,
407                    source: ResolveSource::Cache,
408                    digest: entry.metadata.digest,
409                    verification: None,
410                }))
411            }
412            Ok(None) => Ok(None),
413            Err(RegistryError::DigestMismatch { .. }) => {
414                // Cache corruption - evict and re-fetch
415                warn!(name, version, "cache integrity check failed, evicting");
416                self.cache.evict(name, version).await?;
417                Ok(None)
418            }
419            Err(e) => {
420                warn!(name, version, error = %e, "cache read error");
421                Ok(None)
422            }
423        }
424    }
425
426    /// Resolve a BYOS URL.
427    async fn resolve_byos(&self, url: &str) -> RegistryResult<ResolvedPack> {
428        debug!(url, "resolving BYOS pack");
429
430        // For now, only support HTTPS URLs directly
431        if url.starts_with("https://") || url.starts_with("http://") {
432            let response = reqwest::get(url)
433                .await
434                .map_err(|e| RegistryError::Network {
435                    message: format!("failed to fetch BYOS pack: {}", e),
436                })?;
437
438            if !response.status().is_success() {
439                return Err(RegistryError::NotFound {
440                    name: url.to_string(),
441                    version: "byos".to_string(),
442                });
443            }
444
445            let content = response.text().await.map_err(|e| RegistryError::Network {
446                message: format!("failed to read BYOS response: {}", e),
447            })?;
448
449            let digest = compute_digest(&content);
450            info!(url, digest = %digest, "resolved BYOS pack");
451
452            return Ok(ResolvedPack {
453                content,
454                source: ResolveSource::Byos(url.to_string()),
455                digest,
456                verification: None,
457            });
458        }
459
460        // S3, GCS, Azure would require object_store integration
461        // For now, return not implemented error
462        Err(RegistryError::Config {
463            message: format!("BYOS scheme not yet supported: {}", url),
464        })
465    }
466
467    /// Pre-fetch a pack for offline use.
468    pub async fn prefetch(&self, reference: &str) -> RegistryResult<()> {
469        let pack_ref = PackRef::parse(reference)?;
470
471        match &pack_ref {
472            PackRef::Registry { name, version, .. } => {
473                // Fetch and cache
474                let result = self.client.fetch_pack(name, version, None).await?;
475
476                if let Some(fetch_result) = result {
477                    self.cache
478                        .put(name, version, &fetch_result, Some(self.client.base_url()))
479                        .await?;
480                    info!(name, version, "prefetched pack");
481                }
482                Ok(())
483            }
484            _ => {
485                // Nothing to prefetch for local/bundled
486                Ok(())
487            }
488        }
489    }
490
491    /// Get the cache.
492    pub fn cache(&self) -> &PackCache {
493        &self.cache
494    }
495
496    /// Get the trust store.
497    pub fn trust_store(&self) -> &TrustStore {
498        &self.trust_store
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use tempfile::TempDir;
506
507    #[tokio::test]
508    async fn test_resolve_local_file() {
509        let temp_dir = TempDir::new().unwrap();
510        let pack_path = temp_dir.path().join("test.yaml");
511        fs::write(&pack_path, "name: test\nversion: 1.0.0")
512            .await
513            .unwrap();
514
515        let config = ResolverConfig::default().allow_unsigned();
516        let resolver = PackResolver::with_config(config).unwrap();
517
518        let result = resolver.resolve(pack_path.to_str().unwrap()).await.unwrap();
519
520        assert!(matches!(result.source, ResolveSource::Local(_)));
521        assert!(result.content.contains("name: test"));
522    }
523
524    #[tokio::test]
525    async fn test_resolve_local_file_not_found() {
526        let config = ResolverConfig::default().allow_unsigned();
527        let resolver = PackResolver::with_config(config).unwrap();
528
529        let result = resolver.resolve("/nonexistent/pack.yaml").await;
530        assert!(matches!(result, Err(RegistryError::NotFound { .. })));
531    }
532
533    #[tokio::test]
534    async fn test_resolve_bundled_not_found() {
535        let config = ResolverConfig::default().allow_unsigned();
536        let resolver = PackResolver::with_config(config).unwrap();
537
538        let result = resolver.resolve("nonexistent-pack").await;
539        assert!(matches!(result, Err(RegistryError::NotFound { .. })));
540    }
541
542    #[tokio::test]
543    async fn test_resolve_bundled_from_config_dir() {
544        let temp_dir = TempDir::new().unwrap();
545        let pack_path = temp_dir.path().join("my-pack.yaml");
546        fs::write(&pack_path, "name: my-pack\nversion: 1.0.0")
547            .await
548            .unwrap();
549
550        let config = ResolverConfig::default()
551            .allow_unsigned()
552            .with_bundled_dir(temp_dir.path().to_str().unwrap());
553        let resolver = PackResolver::with_config(config).unwrap();
554
555        let result = resolver.resolve("my-pack").await.unwrap();
556
557        assert!(matches!(result.source, ResolveSource::Bundled(_)));
558        assert!(result.content.contains("name: my-pack"));
559    }
560
561    #[tokio::test]
562    async fn test_with_config_bootstraps_embedded_production_roots() -> RegistryResult<()> {
563        let resolver = PackResolver::with_config(ResolverConfig::default().allow_unsigned())?;
564        let keys = resolver.trust_store().list_keys().await;
565        assert!(!keys.is_empty());
566        Ok(())
567    }
568
569    #[test]
570    fn test_resolve_source_display() {
571        assert_eq!(
572            ResolveSource::Local("/path/to/pack.yaml".to_string()).to_string(),
573            "local:/path/to/pack.yaml"
574        );
575        assert_eq!(
576            ResolveSource::Bundled("my-pack".to_string()).to_string(),
577            "bundled:my-pack"
578        );
579        assert_eq!(ResolveSource::Cache.to_string(), "cache");
580        assert_eq!(
581            ResolveSource::Registry("https://registry.example.com".to_string()).to_string(),
582            "registry:https://registry.example.com"
583        );
584        assert_eq!(
585            ResolveSource::Byos("s3://bucket/pack.yaml".to_string()).to_string(),
586            "byos:s3://bucket/pack.yaml"
587        );
588    }
589}