use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use anyhow::{bail, Context as _, Result};
use oci_client::manifest::OciImageManifest;
use oci_client::{
client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer},
secrets::RegistryAuth,
Reference,
};
use oci_wasm::{ToConfig, WasmConfig, WASM_LAYER_MEDIA_TYPE, WASM_MANIFEST_MEDIA_TYPE};
use provider_archive::ProviderArchive;
use sha2::Digest;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use wasmcloud_core::tls;
const PROVIDER_ARCHIVE_MEDIA_TYPE: &str = "application/vnd.wasmcloud.provider.archive.layer.v1+par";
const PROVIDER_ARCHIVE_CONFIG_MEDIA_TYPE: &str =
"application/vnd.wasmcloud.provider.archive.config";
const WASM_MEDIA_TYPE: &str = "application/vnd.module.wasm.content.layer.v1+wasm";
const OCI_MEDIA_TYPE: &str = "application/vnd.oci.image.layer.v1.tar";
#[derive(Default)]
pub struct OciPullOptions {
pub digest: Option<String>,
pub allow_latest: bool,
pub user: Option<String>,
pub password: Option<String>,
pub insecure: bool,
pub insecure_skip_tls_verify: bool,
}
#[derive(Default)]
pub struct OciPushOptions {
pub config: Option<PathBuf>,
pub allow_latest: bool,
pub user: Option<String>,
pub password: Option<String>,
pub insecure: bool,
pub insecure_skip_tls_verify: bool,
pub annotations: Option<BTreeMap<String, String>>,
pub monolithic_push: bool,
}
pub enum SupportedArtifacts {
Par(Config, ImageLayer),
Wasm(Config, ImageLayer),
}
pub enum ArtifactType {
Par,
Wasm,
}
fn sha256_digest(bytes: &[u8]) -> String {
format!("sha256:{:x}", sha2::Sha256::digest(bytes))
}
pub async fn get_oci_artifact(
url_or_file: String,
cache_file: Option<PathBuf>,
options: OciPullOptions,
) -> Result<Vec<u8>> {
if let Ok(mut local_artifact) = File::open(&url_or_file).await {
let mut buf = Vec::new();
local_artifact.read_to_end(&mut buf).await?;
return Ok(buf);
} else if let Some(cache_path) = cache_file {
if let Ok(mut cached_artifact) = File::open(cache_path).await {
let mut buf = Vec::new();
cached_artifact.read_to_end(&mut buf).await?;
return Ok(buf);
}
}
pull_oci_artifact(
&url_or_file
.try_into()
.context("Unable to parse URL as a reference")?,
options,
)
.await
}
pub async fn pull_oci_artifact(image_ref: &Reference, options: OciPullOptions) -> Result<Vec<u8>> {
let input_tag = image_ref.tag();
if !options.allow_latest {
if let Some(tag) = input_tag {
if tag == "latest" {
bail!("Pulling artifacts with tag 'latest' is prohibited. This can be overridden with the flag '--allow-latest'.");
}
} else {
bail!("Registry URLs must have explicit tag. To default missing tags to 'latest', use the flag '--allow-latest'.");
}
}
let client = Client::new(ClientConfig {
protocol: if options.insecure {
ClientProtocol::Http
} else {
ClientProtocol::Https
},
extra_root_certificates: tls::NATIVE_ROOTS_OCI.to_vec(),
accept_invalid_certificates: options.insecure_skip_tls_verify,
..Default::default()
});
let auth = match (options.user, options.password) {
(Some(user), Some(password)) => RegistryAuth::Basic(user, password),
_ => RegistryAuth::Anonymous,
};
let image_data = client
.pull(
image_ref,
&auth,
vec![
PROVIDER_ARCHIVE_MEDIA_TYPE,
WASM_MEDIA_TYPE,
OCI_MEDIA_TYPE,
WASM_LAYER_MEDIA_TYPE,
],
)
.await?;
let digest = match options.digest {
Some(d) if d.starts_with("sha256:") => Some(d),
Some(d) => Some(format!("sha256:{d}")),
None => None,
};
match (digest, image_data.digest) {
(Some(digest), Some(image_digest)) if digest != image_digest => {
bail!("image digest did not match provided digest, aborting")
}
_ => (),
};
Ok(image_data
.layers
.iter()
.flat_map(|l| l.data.clone())
.collect::<Vec<_>>())
}
pub async fn push_oci_artifact(
url: String,
artifact: impl AsRef<Path>,
options: OciPushOptions,
) -> Result<(Option<String>, String)> {
let image: Reference = url.to_lowercase().parse()?;
if image.tag().unwrap_or_default() == "latest" && !options.allow_latest {
bail!("Pushing artifacts with tag 'latest' is prohibited");
};
let mut artifact_buf = vec![];
let mut f = File::open(&artifact)
.await
.with_context(|| format!("failed to open artifact [{}]", artifact.as_ref().display()))?;
f.read_to_end(&mut artifact_buf).await?;
let (config, layer, is_wasm) = match parse_and_validate_artifact(&artifact_buf).await? {
SupportedArtifacts::Wasm(conf, layer) => (conf, layer, true),
SupportedArtifacts::Par(mut conf, layer) => {
let mut config_buf = vec![];
match options.config {
Some(config_file) => {
let mut f = File::open(&config_file).await.with_context(|| {
format!("failed to open config file [{}]", config_file.display())
})?;
f.read_to_end(&mut config_buf).await?;
}
None => {
config_buf = b"{}".to_vec();
}
};
conf.data = config_buf;
(conf, layer, false)
}
};
let layers = vec![layer];
let client = Client::new(ClientConfig {
protocol: if options.insecure {
ClientProtocol::Http
} else {
ClientProtocol::Https
},
extra_root_certificates: tls::NATIVE_ROOTS_OCI.to_vec(),
accept_invalid_certificates: options.insecure_skip_tls_verify,
use_monolithic_push: options.monolithic_push,
..Default::default()
});
let auth = match (options.user, options.password) {
(Some(user), Some(password)) => RegistryAuth::Basic(user, password),
_ => RegistryAuth::Anonymous,
};
let mut manifest = OciImageManifest::build(&layers, &config, options.annotations);
if is_wasm {
manifest.media_type = Some(WASM_MANIFEST_MEDIA_TYPE.to_string());
}
let digest =
serde_json::to_value(&manifest).map(|value| sha256_digest(value.to_string().as_bytes()))?;
client
.push(&image, &layers, config, &auth, Some(manifest))
.await?;
Ok((image.tag().map(ToString::to_string), digest))
}
pub async fn parse_and_validate_artifact(artifact: &[u8]) -> Result<SupportedArtifacts> {
match parse_component(artifact.to_owned()) {
Ok(art) => Ok(art),
Err(_) => match parse_provider_archive(artifact).await {
Ok(art) => Ok(art),
Err(_) => bail!("Unsupported artifact type"),
},
}
}
pub async fn identify_artifact(artifact: &[u8]) -> Result<ArtifactType> {
if wasmparser::Parser::is_component(artifact) {
return Ok(ArtifactType::Wasm);
}
parse_provider_archive(artifact)
.await
.map(|_| ArtifactType::Par)
}
fn parse_component(artifact: Vec<u8>) -> Result<SupportedArtifacts> {
let (conf, layer) = WasmConfig::from_raw_component(artifact, None)?;
Ok(SupportedArtifacts::Wasm(conf.to_config()?, layer))
}
async fn parse_provider_archive(artifact: &[u8]) -> Result<SupportedArtifacts> {
match ProviderArchive::try_load(artifact).await {
Ok(_par) => Ok(SupportedArtifacts::Par(
Config {
data: Vec::default(),
media_type: PROVIDER_ARCHIVE_CONFIG_MEDIA_TYPE.to_string(),
annotations: None,
},
ImageLayer {
data: artifact.to_owned(),
media_type: PROVIDER_ARCHIVE_MEDIA_TYPE.to_string(),
annotations: None,
},
)),
Err(e) => bail!("Invalid provider archive: {}", e),
}
}