wash_lib/
registry.rs

1//! Utilities for pulling and pushing artifacts to various registries
2
3use std::{
4    collections::BTreeMap,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{bail, Context as _, Result};
9use oci_client::manifest::OciImageManifest;
10use oci_client::{
11    client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer},
12    secrets::RegistryAuth,
13    Reference,
14};
15use oci_wasm::{ToConfig, WasmConfig, WASM_LAYER_MEDIA_TYPE, WASM_MANIFEST_MEDIA_TYPE};
16use provider_archive::ProviderArchive;
17use sha2::Digest;
18use tokio::fs::File;
19use tokio::io::AsyncReadExt;
20use wasmcloud_core::tls;
21
22const PROVIDER_ARCHIVE_MEDIA_TYPE: &str = "application/vnd.wasmcloud.provider.archive.layer.v1+par";
23const PROVIDER_ARCHIVE_CONFIG_MEDIA_TYPE: &str =
24    "application/vnd.wasmcloud.provider.archive.config";
25const WASM_MEDIA_TYPE: &str = "application/vnd.module.wasm.content.layer.v1+wasm";
26const OCI_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
27
28/// Additional options for pulling an OCI artifact
29#[derive(Default)]
30pub struct OciPullOptions {
31    /// The digest of the content you expect to receive. This is used for validation purposes only
32    pub digest: Option<String>,
33    /// By default, we do not allow latest tags in wasmCloud. This overrides that setting
34    pub allow_latest: bool,
35    /// An optional username to use for authentication.
36    pub user: Option<String>,
37    /// An optional password to use for authentication
38    pub password: Option<String>,
39    /// Whether or not to allow pulling from non-https registries
40    pub insecure: bool,
41    /// Whether or not OCI registry's certificate will be checked for validity. This will make your HTTPS connections insecure.
42    pub insecure_skip_tls_verify: bool,
43}
44
45/// Additional options for pushing an OCI artifact
46#[derive(Default)]
47pub struct OciPushOptions {
48    /// A path to an optional OCI configuration
49    pub config: Option<PathBuf>,
50    /// By default, we do not allow latest tags in wasmCloud. This overrides that setting
51    pub allow_latest: bool,
52    /// An optional username to use for authentication.
53    pub user: Option<String>,
54    /// An optional password to use for authentication.
55    pub password: Option<String>,
56    /// Whether or not to allow pulling from non-https registries
57    pub insecure: bool,
58    /// Whether or not OCI registry's certificate will be checked for validity. This will make your HTTPS connections insecure.
59    pub insecure_skip_tls_verify: bool,
60    /// Optional annotations you'd like to add to the pushed artifact
61    pub annotations: Option<BTreeMap<String, String>>,
62    /// Whether to use monolithic push instead of chunked push
63    pub monolithic_push: bool,
64}
65
66/// The types of artifacts that wash supports
67pub enum SupportedArtifacts {
68    /// A par.gz (i.e. parcheezy) file containing capability providers
69    Par(Config, ImageLayer),
70    /// WebAssembly components and its configuration
71    Wasm(Config, ImageLayer),
72}
73
74/// An enum indicating the type of artifact that was pulled
75pub enum ArtifactType {
76    Par,
77    Wasm,
78}
79
80// Based on https://github.com/krustlet/oci-distribution/blob/v0.9.4/src/lib.rs#L25-L28
81// We use this to calculate the sha256 digest for a given manifest so that we can return it
82// back when pushing an artifact to a registry without making a network request for it.
83/// Computes the SHA256 digest of a byte vector
84fn sha256_digest(bytes: &[u8]) -> String {
85    format!("sha256:{:x}", sha2::Sha256::digest(bytes))
86}
87
88// NOTE(thomastaylor312): In later refactors, we might want to consider making some sort of puller
89// and pusher structs that can take optional implementations of a `Cache` trait that does all the
90// cached file handling. But for now, this should be good enough
91
92/// Attempts to return a local artifact, then a cached file (if `cache_file` is set).
93///
94/// Falls back to pull from registry if neither is found.
95pub async fn get_oci_artifact(
96    url_or_file: String,
97    cache_file: Option<PathBuf>,
98    options: OciPullOptions,
99) -> Result<Vec<u8>> {
100    if let Ok(mut local_artifact) = File::open(&url_or_file).await {
101        let mut buf = Vec::new();
102        local_artifact.read_to_end(&mut buf).await?;
103        return Ok(buf);
104    } else if let Some(cache_path) = cache_file {
105        if let Ok(mut cached_artifact) = File::open(cache_path).await {
106            let mut buf = Vec::new();
107            cached_artifact.read_to_end(&mut buf).await?;
108            return Ok(buf);
109        }
110    }
111    pull_oci_artifact(
112        &url_or_file
113            .try_into()
114            .context("Unable to parse URL as a reference")?,
115        options,
116    )
117    .await
118}
119
120/// Pull down the artifact from the given url and additional options
121pub async fn pull_oci_artifact(image_ref: &Reference, options: OciPullOptions) -> Result<Vec<u8>> {
122    let input_tag = image_ref.tag();
123
124    if !options.allow_latest {
125        if let Some(tag) = input_tag {
126            if tag == "latest" {
127                bail!("Pulling artifacts with tag 'latest' is prohibited. This can be overridden with the flag '--allow-latest'.");
128            }
129        } else {
130            bail!("Registry URLs must have explicit tag. To default missing tags to 'latest', use the flag '--allow-latest'.");
131        }
132    }
133
134    let client = Client::new(ClientConfig {
135        protocol: if options.insecure {
136            ClientProtocol::Http
137        } else {
138            ClientProtocol::Https
139        },
140        extra_root_certificates: tls::NATIVE_ROOTS_OCI.to_vec(),
141        accept_invalid_certificates: options.insecure_skip_tls_verify,
142        ..Default::default()
143    });
144
145    let auth = match (options.user, options.password) {
146        (Some(user), Some(password)) => RegistryAuth::Basic(user, password),
147        _ => RegistryAuth::Anonymous,
148    };
149
150    let image_data = client
151        .pull(
152            image_ref,
153            &auth,
154            vec![
155                PROVIDER_ARCHIVE_MEDIA_TYPE,
156                WASM_MEDIA_TYPE,
157                OCI_MEDIA_TYPE,
158                WASM_LAYER_MEDIA_TYPE,
159            ],
160        )
161        .await?;
162
163    // Reformatting digest in case the sha256: prefix is left off
164    let digest = match options.digest {
165        Some(d) if d.starts_with("sha256:") => Some(d),
166        Some(d) => Some(format!("sha256:{d}")),
167        None => None,
168    };
169
170    match (digest, image_data.digest) {
171        (Some(digest), Some(image_digest)) if digest != image_digest => {
172            bail!("image digest did not match provided digest, aborting")
173        }
174        _ => (),
175    };
176
177    Ok(image_data
178        .layers
179        .iter()
180        .flat_map(|l| l.data.clone())
181        .collect::<Vec<_>>())
182}
183
184/// Pushes the artifact to the given repo and returns a tuple containing the tag (if one was set) and the digest
185pub async fn push_oci_artifact(
186    url: String,
187    artifact: impl AsRef<Path>,
188    options: OciPushOptions,
189) -> Result<(Option<String>, String)> {
190    let image: Reference = url.to_lowercase().parse()?;
191
192    if image.tag().unwrap_or_default() == "latest" && !options.allow_latest {
193        bail!("Pushing artifacts with tag 'latest' is prohibited");
194    };
195
196    let mut artifact_buf = vec![];
197    let mut f = File::open(&artifact)
198        .await
199        .with_context(|| format!("failed to open artifact [{}]", artifact.as_ref().display()))?;
200    f.read_to_end(&mut artifact_buf).await?;
201
202    let (config, layer, is_wasm) = match parse_and_validate_artifact(&artifact_buf).await? {
203        SupportedArtifacts::Wasm(conf, layer) => (conf, layer, true),
204        SupportedArtifacts::Par(mut conf, layer) => {
205            let mut config_buf = vec![];
206            match options.config {
207                Some(config_file) => {
208                    let mut f = File::open(&config_file).await.with_context(|| {
209                        format!("failed to open config file [{}]", config_file.display())
210                    })?;
211                    f.read_to_end(&mut config_buf).await?;
212                }
213                None => {
214                    // If no config provided, send blank config
215                    config_buf = b"{}".to_vec();
216                }
217            };
218            conf.data = config_buf;
219            (conf, layer, false)
220        }
221    };
222
223    let layers = vec![layer];
224
225    let client = Client::new(ClientConfig {
226        protocol: if options.insecure {
227            ClientProtocol::Http
228        } else {
229            ClientProtocol::Https
230        },
231        extra_root_certificates: tls::NATIVE_ROOTS_OCI.to_vec(),
232        accept_invalid_certificates: options.insecure_skip_tls_verify,
233        use_monolithic_push: options.monolithic_push,
234        ..Default::default()
235    });
236
237    let auth = match (options.user, options.password) {
238        (Some(user), Some(password)) => RegistryAuth::Basic(user, password),
239        _ => RegistryAuth::Anonymous,
240    };
241
242    let mut manifest = OciImageManifest::build(&layers, &config, options.annotations);
243    if is_wasm {
244        manifest.media_type = Some(WASM_MANIFEST_MEDIA_TYPE.to_string());
245    }
246    // We calculate the sha256 digest from serde_json::Value instead of the OciImageManifest struct, because
247    // when you serialize a struct directly into json, serde_json preserves the ordering of the keys.
248    //
249    // However, the registry implementations that were tested against (mostly based on distribution[1] registry),
250    // all sort their manifests alphabetically (since they are based on Go), which means that when you calculate
251    // the sha256-based digest, the ordering matters, and thus preserving the struct field ordering in the json
252    // output causes the digests to differ.
253    //
254    // This attempts to approximate the ordering as provided by the Go-based registry implementations, which
255    // is/are the prevailing implementation.
256    let digest =
257        serde_json::to_value(&manifest).map(|value| sha256_digest(value.to_string().as_bytes()))?;
258
259    client
260        .push(&image, &layers, config, &auth, Some(manifest))
261        .await?;
262    Ok((image.tag().map(ToString::to_string), digest))
263}
264
265/// Helper function to determine artifact type and parse it into a config and layer ready for use in
266/// pushing to OCI
267pub async fn parse_and_validate_artifact(artifact: &[u8]) -> Result<SupportedArtifacts> {
268    // NOTE(thomastaylor312): I don't like having to clone here, but we need to either clone here or
269    // later when calling parse_component/parse_provider_archive. If this gets to be a
270    // problem, we can always change this, but it is a CLI, so _shrug_
271    match parse_component(artifact.to_owned()) {
272        Ok(art) => Ok(art),
273        Err(_) => match parse_provider_archive(artifact).await {
274            Ok(art) => Ok(art),
275            Err(_) => bail!("Unsupported artifact type"),
276        },
277    }
278}
279
280/// Function that identifies whether the artifact is a component or a provider archive. Returns an
281/// error if it isn't a known type
282// NOTE: This exists because we don't care about parsing the proper world when pulling
283pub async fn identify_artifact(artifact: &[u8]) -> Result<ArtifactType> {
284    if wasmparser::Parser::is_component(artifact) {
285        return Ok(ArtifactType::Wasm);
286    }
287    parse_provider_archive(artifact)
288        .await
289        .map(|_| ArtifactType::Par)
290}
291
292/// Attempts to parse the wit from a component. Fails if it isn't a component
293fn parse_component(artifact: Vec<u8>) -> Result<SupportedArtifacts> {
294    let (conf, layer) = WasmConfig::from_raw_component(artifact, None)?;
295    Ok(SupportedArtifacts::Wasm(conf.to_config()?, layer))
296}
297
298/// Attempts to unpack a provider archive. Will fail without claims or if the archive is invalid
299async fn parse_provider_archive(artifact: &[u8]) -> Result<SupportedArtifacts> {
300    match ProviderArchive::try_load(artifact).await {
301        Ok(_par) => Ok(SupportedArtifacts::Par(
302            Config {
303                data: Vec::default(),
304                media_type: PROVIDER_ARCHIVE_CONFIG_MEDIA_TYPE.to_string(),
305                annotations: None,
306            },
307            ImageLayer {
308                data: artifact.to_owned(),
309                media_type: PROVIDER_ARCHIVE_MEDIA_TYPE.to_string(),
310                annotations: None,
311            },
312        )),
313        Err(e) => bail!("Invalid provider archive: {}", e),
314    }
315}