1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use async_trait::async_trait;
6use oci_distribution::Reference;
7use oci_distribution::client::{Client, ClientConfig, ClientProtocol, ImageData};
8use oci_distribution::errors::OciDistributionError;
9use oci_distribution::manifest::{
10 IMAGE_MANIFEST_LIST_MEDIA_TYPE, IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_INDEX_MEDIA_TYPE,
11 OCI_IMAGE_MEDIA_TYPE,
12};
13use oci_distribution::secrets::RegistryAuth;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18const OCI_ARTIFACT_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.artifact.manifest.v1+json";
19const DOCKER_MANIFEST_MEDIA_TYPE: &str = "application/vnd.docker.distribution.manifest.v2+json";
20const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
21 "application/vnd.docker.distribution.manifest.list.v2+json";
22
23static DEFAULT_ACCEPTED_MANIFEST_TYPES: &[&str] = &[
25 OCI_ARTIFACT_MANIFEST_MEDIA_TYPE,
26 OCI_IMAGE_MEDIA_TYPE,
27 OCI_IMAGE_INDEX_MEDIA_TYPE,
28 IMAGE_MANIFEST_MEDIA_TYPE,
29 IMAGE_MANIFEST_LIST_MEDIA_TYPE,
30 DOCKER_MANIFEST_MEDIA_TYPE,
31 DOCKER_MANIFEST_LIST_MEDIA_TYPE,
32];
33
34const COMPONENT_MANIFEST_MEDIA_TYPE: &str = "application/vnd.greentic.component.manifest+json";
35const DEFAULT_WASM_FILENAME: &str = "component.wasm";
36
37static DEFAULT_LAYER_MEDIA_TYPES: &[&str] = &[
39 "application/vnd.wasm.component.v1+wasm",
40 "application/vnd.module.wasm.content.layer.v1+wasm",
41 "application/wasm",
42 COMPONENT_MANIFEST_MEDIA_TYPE,
43 "application/octet-stream",
44];
45
46#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
48pub struct ComponentsExtension {
49 pub refs: Vec<String>,
50 #[serde(default)]
51 pub mode: ComponentsMode,
52}
53
54#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "lowercase")]
57pub enum ComponentsMode {
58 #[default]
59 Eager,
60 Lazy,
61}
62
63#[derive(Clone, Debug)]
65pub struct ComponentResolveOptions {
66 pub allow_tags: bool,
67 pub offline: bool,
68 pub cache_dir: PathBuf,
69 pub accepted_manifest_types: Vec<String>,
70 pub preferred_layer_media_types: Vec<String>,
71}
72
73impl Default for ComponentResolveOptions {
74 fn default() -> Self {
75 Self {
76 allow_tags: false,
77 offline: false,
78 cache_dir: default_cache_root(),
79 accepted_manifest_types: DEFAULT_ACCEPTED_MANIFEST_TYPES
80 .iter()
81 .map(|s| s.to_string())
82 .collect(),
83 preferred_layer_media_types: DEFAULT_LAYER_MEDIA_TYPES
84 .iter()
85 .map(|s| s.to_string())
86 .collect(),
87 }
88 }
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct ResolvedComponent {
94 pub original_reference: String,
95 pub resolved_digest: String,
96 pub media_type: String,
97 pub path: PathBuf,
98 pub fetched_from_network: bool,
99 pub manifest_digest: Option<String>,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct ResolvedComponentDescriptor {
105 pub original_reference: String,
106 pub resolved_digest: String,
107 pub media_type: String,
108 pub size_bytes: u64,
109 pub fetched_from_network: bool,
110 pub manifest_digest: Option<String>,
111}
112
113#[derive(Debug, Deserialize)]
114struct ComponentManifest {
115 #[serde(default)]
116 artifacts: Option<ComponentManifestArtifacts>,
117}
118
119#[derive(Debug, Deserialize)]
120struct ComponentManifestArtifacts {
121 #[serde(default)]
122 component_wasm: Option<String>,
123}
124
125#[derive(Debug, Serialize, Deserialize)]
126struct CacheMetadata {
127 original_reference: String,
128 resolved_digest: String,
129 media_type: String,
130 fetched_at_unix_seconds: u64,
131 size_bytes: u64,
132 #[serde(default)]
133 manifest_digest: Option<String>,
134 #[serde(default)]
135 manifest_wasm_name: Option<String>,
136}
137
138pub struct OciComponentResolver<C: RegistryClient = DefaultRegistryClient> {
140 client: C,
141 opts: ComponentResolveOptions,
142 cache: OciCache,
143}
144
145impl Default for OciComponentResolver<DefaultRegistryClient> {
146 fn default() -> Self {
147 Self::new(ComponentResolveOptions::default())
148 }
149}
150
151impl<C: RegistryClient> OciComponentResolver<C> {
152 pub fn new(opts: ComponentResolveOptions) -> Self {
153 let cache = OciCache::new(opts.cache_dir.clone());
154 Self {
155 client: C::default_client(),
156 opts,
157 cache,
158 }
159 }
160
161 pub fn with_client(client: C, opts: ComponentResolveOptions) -> Self {
162 let cache = OciCache::new(opts.cache_dir.clone());
163 Self {
164 client,
165 opts,
166 cache,
167 }
168 }
169
170 pub async fn resolve_refs(
171 &self,
172 extension: &ComponentsExtension,
173 ) -> Result<Vec<ResolvedComponent>, OciComponentError> {
174 let mut results = Vec::with_capacity(extension.refs.len());
175 for reference in &extension.refs {
176 results.push(self.resolve_single(reference).await?);
177 }
178 Ok(results)
179 }
180
181 pub async fn resolve_descriptors(
182 &self,
183 extension: &ComponentsExtension,
184 ) -> Result<Vec<ResolvedComponentDescriptor>, OciComponentError> {
185 let mut results = Vec::with_capacity(extension.refs.len());
186 for reference in &extension.refs {
187 results.push(self.resolve_descriptor(reference).await?);
188 }
189 Ok(results)
190 }
191
192 pub async fn resolve_descriptor(
193 &self,
194 reference: &str,
195 ) -> Result<ResolvedComponentDescriptor, OciComponentError> {
196 let parsed =
197 Reference::try_from(reference).map_err(|e| OciComponentError::InvalidReference {
198 reference: reference.to_string(),
199 reason: e.to_string(),
200 })?;
201
202 if parsed.digest().is_none() && !self.opts.allow_tags {
203 return Err(OciComponentError::DigestRequired {
204 reference: reference.to_string(),
205 });
206 }
207
208 let expected_digest = parsed.digest().map(normalize_digest);
209 if let Some(expected_digest) = expected_digest.as_ref() {
210 if let Some(hit) = self.cache.try_descriptor_hit(expected_digest, reference) {
211 return Ok(hit);
212 }
213 if self.opts.offline {
214 return Err(OciComponentError::OfflineMissing {
215 reference: reference.to_string(),
216 digest: expected_digest.clone(),
217 });
218 }
219 } else if self.opts.offline {
220 return Err(OciComponentError::OfflineTaggedReference {
221 reference: reference.to_string(),
222 });
223 }
224
225 let accepted_layer_types = self
226 .opts
227 .preferred_layer_media_types
228 .iter()
229 .map(|s| s.as_str())
230 .collect::<Vec<_>>();
231 let image = self
232 .client
233 .pull(&parsed, &accepted_layer_types)
234 .await
235 .map_err(|source| OciComponentError::PullFailed {
236 reference: reference.to_string(),
237 source,
238 })?;
239
240 let chosen_layer = select_layer(
241 &image.layers,
242 &self.opts.preferred_layer_media_types,
243 reference,
244 )?;
245 let resolved_digest = image
246 .digest
247 .clone()
248 .or_else(|| chosen_layer.digest.clone())
249 .unwrap_or_else(|| compute_digest(&chosen_layer.data));
250 let manifest_digest = image.digest.clone();
251
252 if let Some(expected) = expected_digest.as_ref()
253 && expected != &resolved_digest
254 {
255 return Err(OciComponentError::DigestMismatch {
256 reference: reference.to_string(),
257 expected: expected.clone(),
258 actual: resolved_digest.clone(),
259 });
260 }
261
262 Ok(ResolvedComponentDescriptor {
263 original_reference: reference.to_string(),
264 resolved_digest,
265 media_type: chosen_layer.media_type.clone(),
266 size_bytes: chosen_layer.data.len() as u64,
267 fetched_from_network: true,
268 manifest_digest,
269 })
270 }
271
272 async fn resolve_single(
273 &self,
274 reference: &str,
275 ) -> Result<ResolvedComponent, OciComponentError> {
276 let parsed =
277 Reference::try_from(reference).map_err(|e| OciComponentError::InvalidReference {
278 reference: reference.to_string(),
279 reason: e.to_string(),
280 })?;
281
282 if parsed.digest().is_none() && !self.opts.allow_tags {
283 return Err(OciComponentError::DigestRequired {
284 reference: reference.to_string(),
285 });
286 }
287
288 let expected_digest = parsed.digest().map(normalize_digest);
289 if let Some(expected_digest) = expected_digest.as_ref() {
290 if let Some(hit) = self.cache.try_hit(expected_digest, reference) {
291 return Ok(hit);
292 }
293 if self.opts.offline {
294 return Err(OciComponentError::OfflineMissing {
295 reference: reference.to_string(),
296 digest: expected_digest.clone(),
297 });
298 }
299 } else if self.opts.offline {
300 return Err(OciComponentError::OfflineTaggedReference {
301 reference: reference.to_string(),
302 });
303 }
304
305 let accepted_layer_types = self
306 .opts
307 .preferred_layer_media_types
308 .iter()
309 .map(|s| s.as_str())
310 .collect::<Vec<_>>();
311 let image = self
312 .client
313 .pull(&parsed, &accepted_layer_types)
314 .await
315 .map_err(|source| OciComponentError::PullFailed {
316 reference: reference.to_string(),
317 source,
318 })?;
319
320 let chosen_layer = select_layer(
321 &image.layers,
322 &self.opts.preferred_layer_media_types,
323 reference,
324 )?;
325 let manifest_layer = image
326 .layers
327 .iter()
328 .find(|layer| layer.media_type == COMPONENT_MANIFEST_MEDIA_TYPE);
329 let manifest_wasm_name = if let Some(layer) = manifest_layer {
330 manifest_component_wasm_name(&layer.data, reference)?
331 } else {
332 None
333 };
334 let resolved_digest = image
335 .digest
336 .clone()
337 .or_else(|| chosen_layer.digest.clone())
338 .unwrap_or_else(|| compute_digest(&chosen_layer.data));
339 let manifest_digest = image.digest.clone();
340
341 if let Some(expected) = expected_digest.as_ref()
342 && expected != &resolved_digest
343 {
344 return Err(OciComponentError::DigestMismatch {
345 reference: reference.to_string(),
346 expected: expected.clone(),
347 actual: resolved_digest.clone(),
348 });
349 }
350
351 let path = self.cache.write(
352 &resolved_digest,
353 &chosen_layer.media_type,
354 &chosen_layer.data,
355 reference,
356 manifest_digest.clone(),
357 manifest_wasm_name.as_deref(),
358 )?;
359 if let Some(layer) = manifest_layer
360 && layer.media_type != chosen_layer.media_type
361 {
362 self.cache
363 .write_manifest_layer(&resolved_digest, &layer.data, reference)?;
364 }
365
366 Ok(ResolvedComponent {
367 original_reference: reference.to_string(),
368 resolved_digest,
369 media_type: chosen_layer.media_type.clone(),
370 path,
371 fetched_from_network: true,
372 manifest_digest,
373 })
374 }
375}
376
377fn select_layer<'a>(
378 layers: &'a [PulledLayer],
379 preferred_types: &[String],
380 reference: &str,
381) -> Result<&'a PulledLayer, OciComponentError> {
382 if layers.is_empty() {
383 return Err(OciComponentError::MissingLayers {
384 reference: reference.to_string(),
385 });
386 }
387 for ty in preferred_types {
388 if let Some(layer) = layers.iter().find(|l| &l.media_type == ty) {
389 return Ok(layer);
390 }
391 }
392 Ok(&layers[0])
393}
394
395fn compute_digest(bytes: &[u8]) -> String {
396 let mut hasher = Sha256::new();
397 hasher.update(bytes);
398 format!("sha256:{:x}", hasher.finalize())
399}
400
401fn normalize_digest(digest: &str) -> String {
402 if digest.starts_with("sha256:") {
403 digest.to_string()
404 } else {
405 format!("sha256:{digest}")
406 }
407}
408
409pub(crate) fn default_cache_root() -> PathBuf {
410 if let Ok(root) = std::env::var("GREENTIC_DIST_CACHE_DIR") {
411 return PathBuf::from(root);
412 }
413 if let Some(cache) = dirs_next::cache_dir() {
414 return cache.join("greentic").join("components");
415 }
416 if let Ok(root) = std::env::var("GREENTIC_HOME") {
417 return PathBuf::from(root).join("cache").join("components");
418 }
419 PathBuf::from(".greentic").join("cache").join("components")
420}
421
422fn manifest_component_wasm_name(
423 data: &[u8],
424 reference: &str,
425) -> Result<Option<String>, OciComponentError> {
426 let manifest: ComponentManifest =
427 serde_json::from_slice(data).map_err(|source| OciComponentError::ManifestParse {
428 reference: reference.to_string(),
429 source,
430 })?;
431 let name = manifest
432 .artifacts
433 .and_then(|artifacts| artifacts.component_wasm)
434 .map(|name| name.trim().to_string())
435 .filter(|name| !name.is_empty());
436 if let Some(name) = name.as_deref() {
437 let path = std::path::Path::new(name);
438 if path.components().count() != 1 {
439 return Err(OciComponentError::InvalidManifestWasmName {
440 reference: reference.to_string(),
441 name: name.to_string(),
442 });
443 }
444 }
445 Ok(name)
446}
447
448#[derive(Clone, Debug)]
449struct OciCache {
450 root: PathBuf,
451}
452
453impl OciCache {
454 fn new(root: PathBuf) -> Self {
455 Self { root }
456 }
457
458 fn write_layer_data(
459 &self,
460 digest: &str,
461 media_type: &str,
462 data: &[u8],
463 reference: &str,
464 ) -> Result<PathBuf, OciComponentError> {
465 let dir = self.artifact_dir(digest);
466 fs::create_dir_all(&dir).map_err(|source| OciComponentError::Io {
467 reference: reference.to_string(),
468 source,
469 })?;
470
471 let artifact_path = self.artifact_path_for_media_type(digest, media_type, None);
472 fs::write(&artifact_path, data).map_err(|source| OciComponentError::Io {
473 reference: reference.to_string(),
474 source,
475 })?;
476 Ok(artifact_path)
477 }
478
479 fn write(
480 &self,
481 digest: &str,
482 media_type: &str,
483 data: &[u8],
484 reference: &str,
485 manifest_digest: Option<String>,
486 manifest_wasm_name: Option<&str>,
487 ) -> Result<PathBuf, OciComponentError> {
488 let artifact_path = if media_type == COMPONENT_MANIFEST_MEDIA_TYPE {
489 self.write_layer_data(digest, media_type, data, reference)?
490 } else if let Some(name) = manifest_wasm_name {
491 let path = self.write_named_file(digest, name, data, reference)?;
492 if name != DEFAULT_WASM_FILENAME {
493 self.write_legacy_symlink(self.artifact_dir(digest).as_path(), name);
494 }
495 path
496 } else {
497 self.write_layer_data(digest, media_type, data, reference)?
498 };
499 let dir = self.artifact_dir(digest);
500
501 let metadata = CacheMetadata {
502 original_reference: reference.to_string(),
503 resolved_digest: digest.to_string(),
504 media_type: media_type.to_string(),
505 fetched_at_unix_seconds: SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .unwrap_or_default()
508 .as_secs(),
509 size_bytes: data.len() as u64,
510 manifest_digest,
511 manifest_wasm_name: manifest_wasm_name.map(|name| name.to_string()),
512 };
513 let metadata_path = dir.join("metadata.json");
514 let buf =
515 serde_json::to_vec_pretty(&metadata).map_err(|source| OciComponentError::Serde {
516 reference: reference.to_string(),
517 source,
518 })?;
519 fs::write(&metadata_path, buf).map_err(|source| OciComponentError::Io {
520 reference: reference.to_string(),
521 source,
522 })?;
523
524 Ok(artifact_path)
525 }
526
527 fn write_named_file(
528 &self,
529 digest: &str,
530 filename: &str,
531 data: &[u8],
532 reference: &str,
533 ) -> Result<PathBuf, OciComponentError> {
534 let dir = self.artifact_dir(digest);
535 fs::create_dir_all(&dir).map_err(|source| OciComponentError::Io {
536 reference: reference.to_string(),
537 source,
538 })?;
539 let path = dir.join(filename);
540 fs::write(&path, data).map_err(|source| OciComponentError::Io {
541 reference: reference.to_string(),
542 source,
543 })?;
544 Ok(path)
545 }
546
547 fn write_manifest_layer(
548 &self,
549 digest: &str,
550 data: &[u8],
551 reference: &str,
552 ) -> Result<PathBuf, OciComponentError> {
553 self.write_layer_data(digest, COMPONENT_MANIFEST_MEDIA_TYPE, data, reference)
554 }
555
556 fn try_hit(&self, digest: &str, reference: &str) -> Option<ResolvedComponent> {
557 let metadata = self.read_metadata(digest).ok();
558 let media_type = metadata
559 .as_ref()
560 .map(|m| m.media_type.clone())
561 .unwrap_or_else(|| "application/octet-stream".to_string());
562 let manifest_wasm_name = metadata
563 .as_ref()
564 .and_then(|m| m.manifest_wasm_name.clone())
565 .or_else(|| self.manifest_wasm_name_from_cache(digest, reference));
566 let path =
567 self.artifact_path_for_media_type(digest, &media_type, manifest_wasm_name.as_deref());
568 if !path.exists() {
569 return None;
570 }
571 Some(ResolvedComponent {
572 original_reference: reference.to_string(),
573 resolved_digest: digest.to_string(),
574 media_type,
575 path,
576 fetched_from_network: false,
577 manifest_digest: metadata.and_then(|m| m.manifest_digest),
578 })
579 }
580
581 fn try_descriptor_hit(
582 &self,
583 digest: &str,
584 reference: &str,
585 ) -> Option<ResolvedComponentDescriptor> {
586 let metadata = self.read_metadata(digest).ok();
587 let media_type = metadata
588 .as_ref()
589 .map(|m| m.media_type.clone())
590 .unwrap_or_else(|| "application/octet-stream".to_string());
591 let manifest_wasm_name = metadata
592 .as_ref()
593 .and_then(|m| m.manifest_wasm_name.clone())
594 .or_else(|| self.manifest_wasm_name_from_cache(digest, reference));
595 let path =
596 self.artifact_path_for_media_type(digest, &media_type, manifest_wasm_name.as_deref());
597 if !path.exists() {
598 return None;
599 }
600 let size_bytes = metadata
601 .as_ref()
602 .map(|m| m.size_bytes)
603 .or_else(|| fs::metadata(&path).ok().map(|m| m.len()))
604 .unwrap_or_default();
605 Some(ResolvedComponentDescriptor {
606 original_reference: reference.to_string(),
607 resolved_digest: digest.to_string(),
608 media_type,
609 size_bytes,
610 fetched_from_network: false,
611 manifest_digest: metadata.and_then(|m| m.manifest_digest),
612 })
613 }
614
615 fn read_metadata(&self, digest: &str) -> anyhow::Result<CacheMetadata> {
616 let metadata_path = self.metadata_path(digest);
617 let bytes = fs::read(metadata_path)?;
618 Ok(serde_json::from_slice(&bytes)?)
619 }
620
621 fn artifact_dir(&self, digest: &str) -> PathBuf {
622 self.root.join(trim_digest_prefix(digest))
623 }
624
625 fn artifact_path_for_media_type(
626 &self,
627 digest: &str,
628 media_type: &str,
629 manifest_wasm_name: Option<&str>,
630 ) -> PathBuf {
631 let dir = self.artifact_dir(digest);
632 let filename = if media_type == COMPONENT_MANIFEST_MEDIA_TYPE {
633 "component.manifest.json"
634 } else if let Some(name) = manifest_wasm_name {
635 name
636 } else {
637 DEFAULT_WASM_FILENAME
638 };
639 dir.join(filename)
640 }
641
642 fn manifest_wasm_name_from_cache(&self, digest: &str, reference: &str) -> Option<String> {
643 let path = self.artifact_dir(digest).join("component.manifest.json");
644 if !path.exists() {
645 return None;
646 }
647 let data = fs::read(path).ok()?;
648 manifest_component_wasm_name(&data, reference)
649 .ok()
650 .flatten()
651 }
652
653 fn write_legacy_symlink(&self, dir: &Path, target: &str) {
654 let legacy_path = dir.join(DEFAULT_WASM_FILENAME);
655 if legacy_path.exists() {
656 return;
657 }
658 let target_path = dir.join(target);
659 #[cfg(unix)]
660 {
661 let _ = std::os::unix::fs::symlink(&target_path, &legacy_path);
662 }
663 #[cfg(windows)]
664 {
665 let _ = std::os::windows::fs::symlink_file(&target_path, &legacy_path);
666 }
667 }
668
669 fn metadata_path(&self, digest: &str) -> PathBuf {
670 self.artifact_dir(digest).join("metadata.json")
671 }
672}
673
674fn trim_digest_prefix(digest: &str) -> &str {
675 digest
676 .strip_prefix("sha256:")
677 .unwrap_or_else(|| digest.trim_start_matches('@'))
678}
679
680#[derive(Clone, Debug)]
681pub struct PulledImage {
682 pub digest: Option<String>,
683 pub layers: Vec<PulledLayer>,
684}
685
686#[derive(Clone, Debug)]
687pub struct PulledLayer {
688 pub media_type: String,
689 pub data: Vec<u8>,
690 pub digest: Option<String>,
691}
692
693#[async_trait]
694pub trait RegistryClient: Send + Sync {
695 fn default_client() -> Self
696 where
697 Self: Sized;
698
699 async fn pull(
700 &self,
701 reference: &Reference,
702 accepted_manifest_types: &[&str],
703 ) -> Result<PulledImage, OciDistributionError>;
704}
705
706#[derive(Clone)]
708pub struct DefaultRegistryClient {
709 inner: Client,
710 auth: RegistryClientAuth,
711}
712
713#[derive(Clone, Debug)]
714enum RegistryClientAuth {
715 Anonymous,
716 Basic { username: String, password: String },
717}
718
719impl Default for DefaultRegistryClient {
720 fn default() -> Self {
721 Self::default_client()
722 }
723}
724
725#[async_trait]
726impl RegistryClient for DefaultRegistryClient {
727 fn default_client() -> Self {
728 let config = ClientConfig {
729 protocol: ClientProtocol::Https,
730 ..Default::default()
731 };
732 Self {
733 inner: Client::new(config),
734 auth: RegistryClientAuth::Anonymous,
735 }
736 }
737
738 async fn pull(
739 &self,
740 reference: &Reference,
741 accepted_manifest_types: &[&str],
742 ) -> Result<PulledImage, OciDistributionError> {
743 let auth = match &self.auth {
744 RegistryClientAuth::Anonymous => RegistryAuth::Anonymous,
745 RegistryClientAuth::Basic { username, password } => {
746 RegistryAuth::Basic(username.clone(), password.clone())
747 }
748 };
749 let image = self
750 .inner
751 .pull(reference, &auth, accepted_manifest_types.to_vec())
752 .await?;
753 Ok(convert_image(image))
754 }
755}
756
757impl DefaultRegistryClient {
758 pub fn with_basic_auth(username: impl Into<String>, password: impl Into<String>) -> Self {
759 let mut client = Self::default_client();
760 client.auth = RegistryClientAuth::Basic {
761 username: username.into(),
762 password: password.into(),
763 };
764 client
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 const TEST_DIGEST: &str =
773 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
774
775 #[test]
776 fn select_layer_prefers_wasm_over_manifest() {
777 let layers = vec![
778 PulledLayer {
779 media_type: COMPONENT_MANIFEST_MEDIA_TYPE.to_string(),
780 data: br#"{"name":"demo"}"#.to_vec(),
781 digest: None,
782 },
783 PulledLayer {
784 media_type: "application/wasm".to_string(),
785 data: b"wasm-bytes".to_vec(),
786 digest: None,
787 },
788 ];
789 let opts = ComponentResolveOptions::default();
790
791 let chosen = select_layer(&layers, &opts.preferred_layer_media_types, "ref").unwrap();
792
793 assert_eq!(chosen.media_type, "application/wasm");
794 }
795
796 #[test]
797 fn cache_writes_manifest_and_wasm_paths() {
798 let temp = tempfile::tempdir().unwrap();
799 let cache = OciCache::new(temp.path().to_path_buf());
800 let digest = TEST_DIGEST;
801 let reference = "ghcr.io/greentic/components@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
802
803 let manifest_path = cache
804 .write(
805 digest,
806 COMPONENT_MANIFEST_MEDIA_TYPE,
807 br#"{"name":"demo"}"#,
808 reference,
809 None,
810 None,
811 )
812 .unwrap();
813 assert_eq!(
814 manifest_path.file_name().and_then(|s| s.to_str()),
815 Some("component.manifest.json")
816 );
817 assert!(manifest_path.exists());
818
819 let wasm_path = cache
820 .write(
821 digest,
822 "application/wasm",
823 b"wasm-bytes",
824 reference,
825 None,
826 None,
827 )
828 .unwrap();
829 assert_eq!(
830 wasm_path.file_name().and_then(|s| s.to_str()),
831 Some("component.wasm")
832 );
833 assert!(wasm_path.exists());
834 }
835
836 #[test]
837 fn cache_writes_manifest_named_wasm_file() {
838 let temp = tempfile::tempdir().unwrap();
839 let cache = OciCache::new(temp.path().to_path_buf());
840 let digest = TEST_DIGEST;
841 let reference = "ghcr.io/greentic/components@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
842 let manifest_bytes = br#"{"artifacts":{"component_wasm":"component_templates.wasm"}}"#;
843
844 let manifest_path = cache
845 .write(
846 digest,
847 COMPONENT_MANIFEST_MEDIA_TYPE,
848 manifest_bytes,
849 reference,
850 None,
851 None,
852 )
853 .unwrap();
854 assert!(manifest_path.exists());
855
856 let manifest_name = manifest_component_wasm_name(manifest_bytes, reference)
857 .unwrap()
858 .unwrap();
859 let wasm_path = cache
860 .write(
861 digest,
862 "application/wasm",
863 b"wasm-bytes",
864 reference,
865 None,
866 Some(&manifest_name),
867 )
868 .unwrap();
869 assert!(wasm_path.exists());
870 assert!(cache.artifact_dir(digest).join(&manifest_name).exists());
871 let legacy_path = cache.artifact_dir(digest).join(DEFAULT_WASM_FILENAME);
872 if legacy_path.exists() {
873 let metadata = fs::symlink_metadata(&legacy_path).unwrap();
874 assert!(metadata.file_type().is_symlink());
875 }
876 }
877
878 #[derive(Clone)]
879 struct FakeClient {
880 image: PulledImage,
881 }
882
883 #[async_trait]
884 impl RegistryClient for FakeClient {
885 fn default_client() -> Self {
886 Self {
887 image: PulledImage {
888 digest: None,
889 layers: Vec::new(),
890 },
891 }
892 }
893
894 async fn pull(
895 &self,
896 _reference: &Reference,
897 _accepted_manifest_types: &[&str],
898 ) -> Result<PulledImage, OciDistributionError> {
899 Ok(self.image.clone())
900 }
901 }
902
903 #[tokio::test]
904 async fn resolve_returns_manifest_named_wasm_path() {
905 let temp = tempfile::tempdir().unwrap();
906 let manifest_bytes =
907 br#"{"artifacts":{"component_wasm":"component_templates.wasm"}}"#.to_vec();
908 let image = PulledImage {
909 digest: Some(TEST_DIGEST.to_string()),
910 layers: vec![
911 PulledLayer {
912 media_type: COMPONENT_MANIFEST_MEDIA_TYPE.to_string(),
913 data: manifest_bytes.clone(),
914 digest: None,
915 },
916 PulledLayer {
917 media_type: "application/wasm".to_string(),
918 data: b"wasm-bytes".to_vec(),
919 digest: None,
920 },
921 ],
922 };
923 let client = FakeClient { image };
924 let opts = ComponentResolveOptions {
925 cache_dir: temp.path().to_path_buf(),
926 ..Default::default()
927 };
928 let resolver = OciComponentResolver::with_client(client, opts);
929 let reference = "ghcr.io/greentic/components@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
930
931 let resolved = resolver
932 .resolve_refs(&ComponentsExtension {
933 refs: vec![reference.to_string()],
934 mode: ComponentsMode::Eager,
935 })
936 .await
937 .unwrap();
938 let resolved = &resolved[0];
939 assert_eq!(
940 resolved.path.file_name().and_then(|s| s.to_str()),
941 Some("component_templates.wasm")
942 );
943 let cache_dir = resolved.path.parent().unwrap();
944 assert!(cache_dir.join("component.manifest.json").exists());
945 assert!(cache_dir.join("component_templates.wasm").exists());
946 let legacy_path = cache_dir.join(DEFAULT_WASM_FILENAME);
947 if legacy_path.exists() {
948 let metadata = fs::symlink_metadata(&legacy_path).unwrap();
949 assert!(metadata.file_type().is_symlink());
950 }
951
952 let client_offline = FakeClient {
953 image: PulledImage {
954 digest: None,
955 layers: Vec::new(),
956 },
957 };
958 let opts_offline = ComponentResolveOptions {
959 cache_dir: temp.path().to_path_buf(),
960 offline: true,
961 ..Default::default()
962 };
963 let resolver_offline = OciComponentResolver::with_client(client_offline, opts_offline);
964 let resolved_offline = resolver_offline
965 .resolve_refs(&ComponentsExtension {
966 refs: vec![reference.to_string()],
967 mode: ComponentsMode::Eager,
968 })
969 .await
970 .unwrap();
971 assert_eq!(
972 resolved_offline[0]
973 .path
974 .file_name()
975 .and_then(|s| s.to_str()),
976 Some("component_templates.wasm")
977 );
978 }
979}
980
981fn convert_image(image: ImageData) -> PulledImage {
982 let layers = image
983 .layers
984 .into_iter()
985 .map(|layer| {
986 let digest = format!("sha256:{}", layer.sha256_digest());
987 PulledLayer {
988 media_type: layer.media_type,
989 data: layer.data,
990 digest: Some(digest),
991 }
992 })
993 .collect();
994 PulledImage {
995 digest: image.digest,
996 layers,
997 }
998}
999
1000#[derive(Debug, Error)]
1001pub enum OciComponentError {
1002 #[error("invalid OCI reference `{reference}`: {reason}")]
1003 InvalidReference { reference: String, reason: String },
1004 #[error("digest pin required for `{reference}` (rerun with --allow-tags to permit tag refs)")]
1005 DigestRequired { reference: String },
1006 #[error("offline mode prohibits tagged reference `{reference}`; pin by digest first")]
1007 OfflineTaggedReference { reference: String },
1008 #[error("offline mode could not find cached component for `{reference}` (digest `{digest}`)")]
1009 OfflineMissing { reference: String, digest: String },
1010 #[error("no layers returned for `{reference}`")]
1011 MissingLayers { reference: String },
1012 #[error("component layer missing for `{reference}`; tried media types {media_types}")]
1013 MissingComponent {
1014 reference: String,
1015 media_types: String,
1016 },
1017 #[error("digest mismatch for `{reference}`: expected {expected}, got {actual}")]
1018 DigestMismatch {
1019 reference: String,
1020 expected: String,
1021 actual: String,
1022 },
1023 #[error("failed to pull `{reference}`: {source}")]
1024 PullFailed {
1025 reference: String,
1026 #[source]
1027 source: oci_distribution::errors::OciDistributionError,
1028 },
1029 #[error("io error while caching `{reference}`: {source}")]
1030 Io {
1031 reference: String,
1032 #[source]
1033 source: std::io::Error,
1034 },
1035 #[error("failed to serialize cache metadata for `{reference}`: {source}")]
1036 Serde {
1037 reference: String,
1038 #[source]
1039 source: serde_json::Error,
1040 },
1041 #[error("failed to parse component manifest for `{reference}`: {source}")]
1042 ManifestParse {
1043 reference: String,
1044 #[source]
1045 source: serde_json::Error,
1046 },
1047 #[error("invalid component_wasm filename `{name}` in manifest for `{reference}`")]
1048 InvalidManifestWasmName { reference: String, name: String },
1049}