wasmcloud_core/
oci.rs

1use std::env::temp_dir;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4
5use anyhow::{bail, Context as _};
6use oci_client::client::ClientProtocol;
7use oci_client::client::ImageData;
8use oci_client::Reference;
9use oci_wasm::WASM_LAYER_MEDIA_TYPE;
10use oci_wasm::WASM_MANIFEST_MEDIA_TYPE;
11use tokio::fs;
12use tokio::io::AsyncWriteExt;
13use wascap::jwt;
14
15use crate::RegistryConfig;
16use crate::{tls, UseParFileCache};
17
18const PROVIDER_ARCHIVE_MEDIA_TYPE: &str = "application/vnd.wasmcloud.provider.archive.layer.v1+par";
19const WASM_MEDIA_TYPE: &str = "application/vnd.module.wasm.content.layer.v1+wasm";
20const OCI_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
21
22/// Whether to update an OCI artifact cache
23#[derive(Debug, Default, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum OciArtifactCacheUpdate {
26    /// Do not update the OCI artifact cache
27    #[default]
28    Ignore,
29    /// Update the cache
30    Update,
31}
32
33/// OCI artifact fetcher
34#[derive(Clone, Debug)]
35pub struct OciFetcher {
36    additional_ca_paths: Vec<PathBuf>,
37    allow_latest: bool,
38    allow_insecure: bool,
39    auth: oci_client::secrets::RegistryAuth,
40}
41
42impl Default for OciFetcher {
43    fn default() -> Self {
44        Self {
45            additional_ca_paths: Vec::default(),
46            allow_latest: false,
47            allow_insecure: false,
48            auth: oci_client::secrets::RegistryAuth::Anonymous,
49        }
50    }
51}
52
53impl From<&RegistryConfig> for OciFetcher {
54    fn from(
55        RegistryConfig {
56            auth,
57            allow_latest,
58            allow_insecure,
59            additional_ca_paths,
60            ..
61        }: &RegistryConfig,
62    ) -> Self {
63        Self {
64            auth: auth.into(),
65            allow_latest: *allow_latest,
66            allow_insecure: *allow_insecure,
67            additional_ca_paths: additional_ca_paths.clone(),
68        }
69    }
70}
71
72impl From<RegistryConfig> for OciFetcher {
73    fn from(
74        RegistryConfig {
75            auth,
76            allow_latest,
77            allow_insecure,
78            additional_ca_paths,
79            ..
80        }: RegistryConfig,
81    ) -> Self {
82        Self {
83            auth: auth.into(),
84            allow_latest,
85            allow_insecure,
86            additional_ca_paths,
87        }
88    }
89}
90
91/// Default directory in which OCI artifacts are cached
92pub async fn oci_cache_dir() -> anyhow::Result<PathBuf> {
93    let path = temp_dir().join("wasmcloud_ocicache");
94    if !fs::try_exists(&path).await? {
95        fs::create_dir_all(&path).await?;
96    }
97    Ok(path)
98}
99
100#[allow(unused)]
101async fn cache_oci_image(
102    image: ImageData,
103    cache_filepath: impl AsRef<Path>,
104    digest_filepath: impl AsRef<Path>,
105) -> std::io::Result<()> {
106    let mut cache_file = fs::File::create(cache_filepath).await?;
107    let content = image
108        .layers
109        .into_iter()
110        .flat_map(|l| l.data)
111        .collect::<Vec<_>>();
112    cache_file.write_all(&content).await?;
113    cache_file.flush().await?;
114    if let Some(digest) = image.digest {
115        let mut digest_file = fs::File::create(digest_filepath).await?;
116        digest_file.write_all(digest.as_bytes()).await?;
117        digest_file.flush().await?;
118    }
119    Ok(())
120}
121
122fn prune_filepath(img: &str) -> String {
123    let mut img = img.replace(':', "_");
124    img = img.replace('/', "_");
125    img = img.replace('.', "_");
126    img
127}
128
129/// A type to indicate whether there was a cache hit or miss when loading artifacts
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum CacheResult {
132    Hit,
133    Miss,
134}
135
136impl OciFetcher {
137    /// Fetch an OCI artifact to a path and return that path. Returns the path and whether or not
138    /// there was a cache hit/miss
139    pub async fn fetch_path(
140        &self,
141        output_dir: impl AsRef<Path>,
142        img: impl AsRef<str>,
143        accepted_media_types: Vec<&str>,
144        cache: OciArtifactCacheUpdate,
145    ) -> anyhow::Result<(PathBuf, CacheResult)> {
146        let output_dir = output_dir.as_ref();
147        let img = img.as_ref().to_lowercase(); // the OCI spec does not allow for capital letters in references
148        if !self.allow_latest && img.ends_with(":latest") {
149            bail!("fetching images tagged 'latest' is currently prohibited in this host. This option can be overridden with WASMCLOUD_OCI_ALLOW_LATEST")
150        }
151        let pruned_filepath = prune_filepath(&img);
152        let cache_file = output_dir.join(&pruned_filepath);
153        let mut digest_file = output_dir.join(&pruned_filepath).clone();
154        digest_file.set_extension("digest");
155
156        let img = Reference::from_str(&img)?;
157
158        let protocol = if self.allow_insecure {
159            ClientProtocol::HttpsExcept(vec![img.registry().to_string()])
160        } else {
161            ClientProtocol::Https
162        };
163        let mut certs = tls::NATIVE_ROOTS_OCI.to_vec();
164        if !self.additional_ca_paths.is_empty() {
165            certs.extend(
166                tls::load_certs_from_paths(&self.additional_ca_paths)
167                    .context("failed to load CA certs from provided paths")?
168                    .iter()
169                    .map(|cert| oci_client::client::Certificate {
170                        encoding: oci_client::client::CertificateEncoding::Der,
171                        data: cert.to_vec(),
172                    }),
173            );
174        }
175        let c = oci_client::Client::new(oci_client::client::ClientConfig {
176            protocol,
177            extra_root_certificates: certs,
178            ..Default::default()
179        });
180
181        // In case of a cache miss where the file does not exist, pull a fresh OCI Image
182        if fs::metadata(&cache_file).await.is_ok() {
183            let (_, oci_digest) = c
184                .pull_manifest(&img, &self.auth)
185                .await
186                .context("failed to fetch OCI manifest")?;
187            // If the digest file doesn't exist that is ok, we just unwrap to an empty string
188            let file_digest = fs::read_to_string(&digest_file).await.unwrap_or_default();
189            if !oci_digest.is_empty() && !file_digest.is_empty() && file_digest == oci_digest {
190                return Ok((cache_file, CacheResult::Hit));
191            }
192        }
193
194        let imgdata = c
195            .pull(&img, &self.auth, accepted_media_types)
196            .await
197            .context("failed to fetch OCI bytes")?;
198        // As a client, we should reject invalid OCI artifacts
199        if imgdata
200            .manifest
201            .as_ref()
202            .map(|m| m.media_type.as_deref().unwrap_or_default() == WASM_MANIFEST_MEDIA_TYPE)
203            .unwrap_or(false)
204            && imgdata.layers.len() > 1
205        {
206            bail!(
207                "Found invalid OCI wasm artifact, expected single layer, found {} layers",
208                imgdata.layers.len()
209            )
210        }
211        // Update the OCI artifact cache if specified
212        if let OciArtifactCacheUpdate::Update = cache {
213            cache_oci_image(imgdata, &cache_file, digest_file)
214                .await
215                .context("failed to cache OCI bytes")?;
216        }
217
218        Ok((cache_file, CacheResult::Miss))
219    }
220
221    /// Fetch component from OCI
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if either fetching fails or reading the fetched OCI path fails
226    pub async fn fetch_component(&self, oci_ref: impl AsRef<str>) -> anyhow::Result<Vec<u8>> {
227        let (path, _) = self
228            .fetch_path(
229                oci_cache_dir().await?,
230                oci_ref,
231                vec![WASM_MEDIA_TYPE, OCI_MEDIA_TYPE, WASM_LAYER_MEDIA_TYPE],
232                OciArtifactCacheUpdate::Update,
233            )
234            .await
235            .context("failed to fetch OCI path")?;
236        fs::read(&path)
237            .await
238            .with_context(|| format!("failed to read `{}`", path.display()))
239    }
240
241    /// Fetch provider from OCI
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if either fetching fails or reading the fetched OCI path fails
246    pub async fn fetch_provider(
247        &self,
248        oci_ref: impl AsRef<str>,
249        host_id: impl AsRef<str>,
250    ) -> anyhow::Result<(PathBuf, Option<jwt::Token<jwt::CapabilityProvider>>)> {
251        let (path, cache) = self
252            .fetch_path(
253                oci_cache_dir().await?,
254                oci_ref.as_ref(),
255                vec![PROVIDER_ARCHIVE_MEDIA_TYPE, OCI_MEDIA_TYPE],
256                OciArtifactCacheUpdate::Update,
257            )
258            .await
259            .context("failed to fetch OCI path")?;
260        let should_cache = match cache {
261            CacheResult::Miss => UseParFileCache::Ignore,
262            CacheResult::Hit => UseParFileCache::Use,
263        };
264        crate::par::read(&path, host_id, oci_ref, should_cache)
265            .await
266            .with_context(|| format!("failed to read `{}`", path.display()))
267    }
268
269    /// Used to set additional CA paths that will be used as part of fetching components and providers
270    pub fn with_additional_ca_paths(mut self, paths: &[impl AsRef<Path>]) -> Self {
271        self.additional_ca_paths = paths.iter().map(AsRef::as_ref).map(PathBuf::from).collect();
272        self
273    }
274}