1use 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#[derive(Debug, Clone)]
25pub struct ResolvedPack {
26 pub content: String,
28
29 pub source: ResolveSource,
31
32 pub digest: String,
34
35 pub verification: Option<VerifyResult>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ResolveSource {
42 Local(String),
44
45 Bundled(String),
47
48 Cache,
50
51 Registry(String),
53
54 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#[derive(Debug, Clone)]
72pub struct ResolverConfig {
73 pub registry: RegistryConfig,
75
76 pub no_cache: bool,
78
79 pub allow_unsigned: bool,
81
82 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 pub fn no_cache(mut self) -> Self {
100 self.no_cache = true;
101 self
102 }
103
104 pub fn allow_unsigned(mut self) -> Self {
106 self.allow_unsigned = true;
107 self
108 }
109
110 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
117pub struct PackResolver {
119 client: RegistryClient,
121
122 cache: PackCache,
124
125 trust_store: TrustStore,
127
128 config: ResolverConfig,
130}
131
132impl PackResolver {
133 pub fn new() -> RegistryResult<Self> {
135 Self::with_config(ResolverConfig::default())
136 }
137
138 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 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 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 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 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, })
217 }
218
219 async fn resolve_bundled(&self, name: &str) -> RegistryResult<ResolvedPack> {
221 debug!(name, "resolving bundled pack");
222
223 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 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 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 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 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 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 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 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 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 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 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 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 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 async fn resolve_byos(&self, url: &str) -> RegistryResult<ResolvedPack> {
428 debug!(url, "resolving BYOS pack");
429
430 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 Err(RegistryError::Config {
463 message: format!("BYOS scheme not yet supported: {}", url),
464 })
465 }
466
467 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 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 Ok(())
487 }
488 }
489 }
490
491 pub fn cache(&self) -> &PackCache {
493 &self.cache
494 }
495
496 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}