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#[derive(Debug, Default, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum OciArtifactCacheUpdate {
26 #[default]
28 Ignore,
29 Update,
31}
32
33#[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
91pub 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#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum CacheResult {
132 Hit,
133 Miss,
134}
135
136impl OciFetcher {
137 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(); 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 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 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 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 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 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 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 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}