1use anyhow::Context;
2use base64::{
3 Engine,
4 engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
5};
6use oci_client::client::{Certificate, CertificateEncoding, ClientConfig};
7use secrecy::{ExposeSecret, SecretString};
8use serde::{Deserialize, Serialize, Serializer};
9use wasm_pkg_common::{Error, config::RegistryConfig};
10
11#[derive(Default, Serialize)]
15#[serde(into = "OciRegistryConfigToml")]
16pub struct OciRegistryConfig {
17 pub client_config: ClientConfig,
18 pub credentials: Option<BasicCredentials>,
19}
20
21impl Clone for OciRegistryConfig {
22 fn clone(&self) -> Self {
23 let client_config = ClientConfig {
24 protocol: self.client_config.protocol.clone(),
25 extra_root_certificates: self.client_config.extra_root_certificates.clone(),
26 tls_certs_only: self.client_config.tls_certs_only.clone(),
27 platform_resolver: None,
28 http_proxy: self.client_config.http_proxy.clone(),
29 https_proxy: self.client_config.https_proxy.clone(),
30 no_proxy: self.client_config.no_proxy.clone(),
31 ..self.client_config
32 };
33 Self {
34 client_config,
35 credentials: self.credentials.clone(),
36 }
37 }
38}
39
40impl std::fmt::Debug for OciRegistryConfig {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("OciConfig")
43 .field("client_config", &"...")
44 .field("credentials", &self.credentials)
45 .finish()
46 }
47}
48
49impl TryFrom<&RegistryConfig> for OciRegistryConfig {
50 type Error = Error;
51
52 fn try_from(registry_config: &RegistryConfig) -> Result<Self, Self::Error> {
53 let OciRegistryConfigToml {
54 auth,
55 protocol,
56 accept_invalid_certificates,
57 extra_root_certificates,
58 } = registry_config.backend_config("oci")?.unwrap_or_default();
59 let mut client_config = ClientConfig::default();
60 if let Some(protocol) = protocol {
61 client_config.protocol = oci_client_protocol(&protocol)?;
62 };
63 let credentials = auth
64 .map(TryInto::try_into)
65 .transpose()
66 .map_err(Error::InvalidConfig)?;
67 client_config.accept_invalid_certificates = accept_invalid_certificates;
68 client_config.extra_root_certificates = extra_root_certificates
69 .into_iter()
70 .map(TryInto::try_into)
71 .collect::<Result<Vec<_>, _>>()
72 .map_err(Error::InvalidConfig)?;
73 Ok(Self {
74 client_config,
75 credentials,
76 })
77 }
78}
79
80#[derive(Default, Deserialize, Serialize)]
81struct OciRegistryConfigToml {
82 auth: Option<TomlAuth>,
83 protocol: Option<String>,
84 #[serde(default)]
85 accept_invalid_certificates: bool,
86 #[serde(default)]
87 extra_root_certificates: Vec<TomlCertificate>,
88}
89
90impl From<OciRegistryConfig> for OciRegistryConfigToml {
91 fn from(value: OciRegistryConfig) -> Self {
92 let OciRegistryConfig {
93 client_config,
94 credentials,
95 } = value;
96
97 OciRegistryConfigToml {
98 auth: credentials.map(|c| TomlAuth::UsernamePassword {
99 username: c.username,
100 password: c.password,
101 }),
102 protocol: Some(oci_protocol_string(&client_config.protocol)),
103 accept_invalid_certificates: client_config.accept_invalid_certificates,
104 extra_root_certificates: client_config
105 .extra_root_certificates
106 .into_iter()
107 .map(Into::into)
108 .collect(),
109 }
110 }
111}
112
113#[derive(Deserialize, Serialize)]
114#[serde(untagged)]
115#[serde(deny_unknown_fields)]
116enum TomlAuth {
117 #[serde(serialize_with = "serialize_secret")]
118 Base64(SecretString),
119 UsernamePassword {
120 username: String,
121 #[serde(serialize_with = "serialize_secret")]
122 password: SecretString,
123 },
124}
125
126#[derive(Clone, Debug)]
127pub struct BasicCredentials {
128 pub username: String,
129 pub password: SecretString,
130}
131
132const OCI_AUTH_BASE64: GeneralPurpose = GeneralPurpose::new(
133 &base64::alphabet::STANDARD,
134 GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
135);
136
137impl TryFrom<TomlAuth> for BasicCredentials {
138 type Error = anyhow::Error;
139
140 fn try_from(value: TomlAuth) -> Result<Self, Self::Error> {
141 match value {
142 TomlAuth::Base64(b64) => {
143 fn decode_b64_creds(b64: &str) -> anyhow::Result<BasicCredentials> {
144 let bs = OCI_AUTH_BASE64.decode(b64)?;
145 let s = String::from_utf8(bs)?;
146 let (username, password) = s
147 .split_once(':')
148 .context("expected <username>:<password> but no ':' found")?;
149 Ok(BasicCredentials {
150 username: username.into(),
151 password: password.to_string().into(),
152 })
153 }
154 decode_b64_creds(b64.expose_secret()).context("invalid base64-encoded creds")
155 }
156 TomlAuth::UsernamePassword { username, password } => {
157 Ok(BasicCredentials { username, password })
158 }
159 }
160 }
161}
162
163fn oci_client_protocol(text: &str) -> Result<oci_client::client::ClientProtocol, Error> {
164 match text {
165 "http" => Ok(oci_client::client::ClientProtocol::Http),
166 "https" => Ok(oci_client::client::ClientProtocol::Https),
167 _ => Err(Error::InvalidConfig(anyhow::anyhow!(
168 "Unknown OCI protocol {text:?}"
169 ))),
170 }
171}
172
173fn oci_protocol_string(protocol: &oci_client::client::ClientProtocol) -> String {
174 match protocol {
175 oci_client::client::ClientProtocol::Http => "http".into(),
176 oci_client::client::ClientProtocol::Https => "https".into(),
177 _ => "https".into(),
179 }
180}
181
182fn serialize_secret<S: Serializer>(
183 secret: &SecretString,
184 serializer: S,
185) -> Result<S::Ok, S::Error> {
186 secret.expose_secret().serialize(serializer)
187}
188
189#[derive(Clone, Deserialize, Serialize)]
190#[serde(rename_all = "lowercase")]
191enum TomlCertificateEncoding {
192 Der,
193 Pem,
194}
195
196#[derive(Clone, Deserialize, Serialize)]
197struct TomlCertificate {
198 encoding: TomlCertificateEncoding,
199 data: String,
200}
201
202impl TryFrom<TomlCertificate> for Certificate {
203 type Error = anyhow::Error;
204
205 fn try_from(value: TomlCertificate) -> Result<Self, Self::Error> {
206 let (encoding, data) = match value.encoding {
207 TomlCertificateEncoding::Der => (CertificateEncoding::Der, value.data.into_bytes()),
208 TomlCertificateEncoding::Pem => (CertificateEncoding::Pem, value.data.into_bytes()),
209 };
210 Ok(Self { encoding, data })
211 }
212}
213
214impl From<Certificate> for TomlCertificate {
215 fn from(value: Certificate) -> Self {
216 let (encoding, data) = match value.encoding {
217 CertificateEncoding::Der => (
218 TomlCertificateEncoding::Der,
219 String::from_utf8_lossy(&value.data).into_owned(),
220 ),
221 CertificateEncoding::Pem => (
222 TomlCertificateEncoding::Pem,
223 String::from_utf8_lossy(&value.data).into_owned(),
224 ),
225 };
226 Self { encoding, data }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use wasm_pkg_common::config::RegistryMapping;
233
234 use crate::oci::OciRegistryMetadata;
235
236 use super::*;
237
238 #[test]
239 fn smoke_test() {
240 let toml_config = r#"
241 [registry."example.com"]
242 type = "oci"
243 [registry."example.com".oci]
244 auth = { username = "open", password = "sesame" }
245 protocol = "http"
246
247 [registry."wasi.dev"]
248 type = "oci"
249 [registry."wasi.dev".oci]
250 auth = "cGluZzpwb25n"
251 "#;
252 let cfg = wasm_pkg_common::config::Config::from_toml(toml_config).unwrap();
253
254 let oci_config: OciRegistryConfig = cfg
255 .registry_config(&"example.com".parse().unwrap())
256 .unwrap()
257 .try_into()
258 .unwrap();
259 let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap();
260 assert_eq!(username, "open");
261 assert_eq!(password.expose_secret(), "sesame");
262 assert_eq!(
263 oci_client::client::ClientProtocol::Http,
264 oci_config.client_config.protocol
265 );
266 assert!(!oci_config.client_config.accept_invalid_certificates);
267 assert!(oci_config.client_config.extra_root_certificates.is_empty());
268
269 let oci_config: OciRegistryConfig = cfg
270 .registry_config(&"wasi.dev".parse().unwrap())
271 .unwrap()
272 .try_into()
273 .unwrap();
274 let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap();
275 assert_eq!(username, "ping");
276 assert_eq!(password.expose_secret(), "pong");
277 }
278
279 #[test]
280 fn test_roundtrip() {
281 let config = OciRegistryConfig {
282 client_config: oci_client::client::ClientConfig {
283 protocol: oci_client::client::ClientProtocol::Http,
284 ..Default::default()
285 },
286 credentials: Some(BasicCredentials {
287 username: "open".into(),
288 password: SecretString::new("sesame".into()),
289 }),
290 };
291
292 let mut conf = crate::Config::empty();
294
295 let registry: crate::Registry = "example.com:8080".parse().unwrap();
296 let reg_conf = conf.get_or_insert_registry_config_mut(®istry);
297 reg_conf
298 .set_backend_config("oci", &config)
299 .expect("Unable to set config");
300
301 let reg_conf = conf.registry_config(®istry).unwrap();
302
303 let roundtripped = OciRegistryConfig::try_from(reg_conf).expect("Unable to load config");
304 assert_eq!(
305 roundtripped.client_config.protocol, config.client_config.protocol,
306 "Home url should be set to the right value"
307 );
308 let creds = config.credentials.unwrap();
309 let roundtripped_creds = roundtripped.credentials.expect("Should have creds");
310 assert_eq!(
311 creds.username, roundtripped_creds.username,
312 "Username should be set to the right value"
313 );
314 assert_eq!(
315 creds.password.expose_secret(),
316 roundtripped_creds.password.expose_secret(),
317 "Password should be set to the right value"
318 );
319 }
320
321 #[test]
322 fn test_custom_namespace_config() {
323 let toml_config = toml::toml! {
324 [namespace_registries]
325 test = { registry = "localhost:1234", metadata = { preferredProtocol = "oci", "oci" = { registry = "ghcr.io", namespacePrefix = "webassembly/" } } }
326 };
327
328 let cfg = wasm_pkg_common::config::Config::from_toml(&toml_config.to_string())
329 .expect("Should be able to load config");
330
331 let ns_config = cfg
332 .namespace_registry(&"test".parse().unwrap())
333 .expect("Should have a namespace config");
334 let custom = match ns_config {
335 RegistryMapping::Custom(c) => c,
336 _ => panic!("Should have a custom namespace config"),
337 };
338 let map: OciRegistryMetadata = custom
339 .metadata
340 .protocol_config("oci")
341 .expect("Should be able to deserialize config")
342 .expect("protocol config should be present");
343 assert_eq!(map.namespace_prefix, Some("webassembly/".to_string()));
344 assert_eq!(map.registry, Some("ghcr.io".to_string()));
345 }
346}