Skip to main content

greentic_distributor_client/
oci_components.rs

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
23/// Accepted manifest media types when pulling components.
24static 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
37/// Preferred component layer media types.
38static 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/// Greentic pack extension for components.
47#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
48pub struct ComponentsExtension {
49    pub refs: Vec<String>,
50    #[serde(default)]
51    pub mode: ComponentsMode,
52}
53
54/// Pull mode for components.
55#[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/// Configuration for resolving OCI component references.
64#[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/// Result of resolving a single component reference.
92#[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/// Descriptor-time resolution result for a single OCI component reference.
103#[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
138/// Resolve OCI component references with caching and offline support.
139pub 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/// Registry client backed by `oci-distribution` with HTTPS enforced and anonymous pulls.
707#[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}