use anyhow::Context;
use base64::{
Engine,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
use oci_client::client::{Certificate, CertificateEncoding, ClientConfig};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize, Serializer};
use wasm_pkg_common::{Error, config::RegistryConfig};
#[derive(Default, Serialize)]
#[serde(into = "OciRegistryConfigToml")]
pub struct OciRegistryConfig {
pub client_config: ClientConfig,
pub credentials: Option<BasicCredentials>,
}
impl Clone for OciRegistryConfig {
fn clone(&self) -> Self {
let client_config = ClientConfig {
protocol: self.client_config.protocol.clone(),
extra_root_certificates: self.client_config.extra_root_certificates.clone(),
tls_certs_only: self.client_config.tls_certs_only.clone(),
platform_resolver: None,
http_proxy: self.client_config.http_proxy.clone(),
https_proxy: self.client_config.https_proxy.clone(),
no_proxy: self.client_config.no_proxy.clone(),
..self.client_config
};
Self {
client_config,
credentials: self.credentials.clone(),
}
}
}
impl std::fmt::Debug for OciRegistryConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OciConfig")
.field("client_config", &"...")
.field("credentials", &self.credentials)
.finish()
}
}
impl TryFrom<&RegistryConfig> for OciRegistryConfig {
type Error = Error;
fn try_from(registry_config: &RegistryConfig) -> Result<Self, Self::Error> {
let OciRegistryConfigToml {
auth,
protocol,
accept_invalid_certificates,
extra_root_certificates,
} = registry_config.backend_config("oci")?.unwrap_or_default();
let mut client_config = ClientConfig::default();
if let Some(protocol) = protocol {
client_config.protocol = oci_client_protocol(&protocol)?;
};
let credentials = auth
.map(TryInto::try_into)
.transpose()
.map_err(Error::InvalidConfig)?;
client_config.accept_invalid_certificates = accept_invalid_certificates;
client_config.extra_root_certificates = extra_root_certificates
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()
.map_err(Error::InvalidConfig)?;
Ok(Self {
client_config,
credentials,
})
}
}
#[derive(Default, Deserialize, Serialize)]
struct OciRegistryConfigToml {
auth: Option<TomlAuth>,
protocol: Option<String>,
#[serde(default)]
accept_invalid_certificates: bool,
#[serde(default)]
extra_root_certificates: Vec<TomlCertificate>,
}
impl From<OciRegistryConfig> for OciRegistryConfigToml {
fn from(value: OciRegistryConfig) -> Self {
let OciRegistryConfig {
client_config,
credentials,
} = value;
OciRegistryConfigToml {
auth: credentials.map(|c| TomlAuth::UsernamePassword {
username: c.username,
password: c.password,
}),
protocol: Some(oci_protocol_string(&client_config.protocol)),
accept_invalid_certificates: client_config.accept_invalid_certificates,
extra_root_certificates: client_config
.extra_root_certificates
.into_iter()
.map(Into::into)
.collect(),
}
}
}
#[derive(Deserialize, Serialize)]
#[serde(untagged)]
#[serde(deny_unknown_fields)]
enum TomlAuth {
#[serde(serialize_with = "serialize_secret")]
Base64(SecretString),
UsernamePassword {
username: String,
#[serde(serialize_with = "serialize_secret")]
password: SecretString,
},
}
#[derive(Clone, Debug)]
pub struct BasicCredentials {
pub username: String,
pub password: SecretString,
}
const OCI_AUTH_BASE64: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
impl TryFrom<TomlAuth> for BasicCredentials {
type Error = anyhow::Error;
fn try_from(value: TomlAuth) -> Result<Self, Self::Error> {
match value {
TomlAuth::Base64(b64) => {
fn decode_b64_creds(b64: &str) -> anyhow::Result<BasicCredentials> {
let bs = OCI_AUTH_BASE64.decode(b64)?;
let s = String::from_utf8(bs)?;
let (username, password) = s
.split_once(':')
.context("expected <username>:<password> but no ':' found")?;
Ok(BasicCredentials {
username: username.into(),
password: password.to_string().into(),
})
}
decode_b64_creds(b64.expose_secret()).context("invalid base64-encoded creds")
}
TomlAuth::UsernamePassword { username, password } => {
Ok(BasicCredentials { username, password })
}
}
}
}
fn oci_client_protocol(text: &str) -> Result<oci_client::client::ClientProtocol, Error> {
match text {
"http" => Ok(oci_client::client::ClientProtocol::Http),
"https" => Ok(oci_client::client::ClientProtocol::Https),
_ => Err(Error::InvalidConfig(anyhow::anyhow!(
"Unknown OCI protocol {text:?}"
))),
}
}
fn oci_protocol_string(protocol: &oci_client::client::ClientProtocol) -> String {
match protocol {
oci_client::client::ClientProtocol::Http => "http".into(),
oci_client::client::ClientProtocol::Https => "https".into(),
_ => "https".into(),
}
}
fn serialize_secret<S: Serializer>(
secret: &SecretString,
serializer: S,
) -> Result<S::Ok, S::Error> {
secret.expose_secret().serialize(serializer)
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
enum TomlCertificateEncoding {
Der,
Pem,
}
#[derive(Clone, Deserialize, Serialize)]
struct TomlCertificate {
encoding: TomlCertificateEncoding,
data: String,
}
impl TryFrom<TomlCertificate> for Certificate {
type Error = anyhow::Error;
fn try_from(value: TomlCertificate) -> Result<Self, Self::Error> {
let (encoding, data) = match value.encoding {
TomlCertificateEncoding::Der => (CertificateEncoding::Der, value.data.into_bytes()),
TomlCertificateEncoding::Pem => (CertificateEncoding::Pem, value.data.into_bytes()),
};
Ok(Self { encoding, data })
}
}
impl From<Certificate> for TomlCertificate {
fn from(value: Certificate) -> Self {
let (encoding, data) = match value.encoding {
CertificateEncoding::Der => (
TomlCertificateEncoding::Der,
String::from_utf8_lossy(&value.data).into_owned(),
),
CertificateEncoding::Pem => (
TomlCertificateEncoding::Pem,
String::from_utf8_lossy(&value.data).into_owned(),
),
};
Self { encoding, data }
}
}
#[cfg(test)]
mod tests {
use wasm_pkg_common::config::RegistryMapping;
use crate::oci::OciRegistryMetadata;
use super::*;
#[test]
fn smoke_test() {
let toml_config = r#"
[registry."example.com"]
type = "oci"
[registry."example.com".oci]
auth = { username = "open", password = "sesame" }
protocol = "http"
[registry."wasi.dev"]
type = "oci"
[registry."wasi.dev".oci]
auth = "cGluZzpwb25n"
"#;
let cfg = wasm_pkg_common::config::Config::from_toml(toml_config).unwrap();
let oci_config: OciRegistryConfig = cfg
.registry_config(&"example.com".parse().unwrap())
.unwrap()
.try_into()
.unwrap();
let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap();
assert_eq!(username, "open");
assert_eq!(password.expose_secret(), "sesame");
assert_eq!(
oci_client::client::ClientProtocol::Http,
oci_config.client_config.protocol
);
assert!(!oci_config.client_config.accept_invalid_certificates);
assert!(oci_config.client_config.extra_root_certificates.is_empty());
let oci_config: OciRegistryConfig = cfg
.registry_config(&"wasi.dev".parse().unwrap())
.unwrap()
.try_into()
.unwrap();
let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap();
assert_eq!(username, "ping");
assert_eq!(password.expose_secret(), "pong");
}
#[test]
fn test_roundtrip() {
let config = OciRegistryConfig {
client_config: oci_client::client::ClientConfig {
protocol: oci_client::client::ClientProtocol::Http,
..Default::default()
},
credentials: Some(BasicCredentials {
username: "open".into(),
password: SecretString::new("sesame".into()),
}),
};
let mut conf = crate::Config::empty();
let registry: crate::Registry = "example.com:8080".parse().unwrap();
let reg_conf = conf.get_or_insert_registry_config_mut(®istry);
reg_conf
.set_backend_config("oci", &config)
.expect("Unable to set config");
let reg_conf = conf.registry_config(®istry).unwrap();
let roundtripped = OciRegistryConfig::try_from(reg_conf).expect("Unable to load config");
assert_eq!(
roundtripped.client_config.protocol, config.client_config.protocol,
"Home url should be set to the right value"
);
let creds = config.credentials.unwrap();
let roundtripped_creds = roundtripped.credentials.expect("Should have creds");
assert_eq!(
creds.username, roundtripped_creds.username,
"Username should be set to the right value"
);
assert_eq!(
creds.password.expose_secret(),
roundtripped_creds.password.expose_secret(),
"Password should be set to the right value"
);
}
#[test]
fn test_custom_namespace_config() {
let toml_config = toml::toml! {
[namespace_registries]
test = { registry = "localhost:1234", metadata = { preferredProtocol = "oci", "oci" = { registry = "ghcr.io", namespacePrefix = "webassembly/" } } }
};
let cfg = wasm_pkg_common::config::Config::from_toml(&toml_config.to_string())
.expect("Should be able to load config");
let ns_config = cfg
.namespace_registry(&"test".parse().unwrap())
.expect("Should have a namespace config");
let custom = match ns_config {
RegistryMapping::Custom(c) => c,
_ => panic!("Should have a custom namespace config"),
};
let map: OciRegistryMetadata = custom
.metadata
.protocol_config("oci")
.expect("Should be able to deserialize config")
.expect("protocol config should be present");
assert_eq!(map.namespace_prefix, Some("webassembly/".to_string()));
assert_eq!(map.registry, Some("ghcr.io".to_string()));
}
}