Skip to main content

wasm_pkg_client/oci/
config.rs

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/// Registry configuration for OCI backends.
12///
13/// See: [`RegistryConfig::backend_config`]
14#[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        // Default to https if not specified
178        _ => "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        // Set the data and then try to load it back
293        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(&registry);
297        reg_conf
298            .set_backend_config("oci", &config)
299            .expect("Unable to set config");
300
301        let reg_conf = conf.registry_config(&registry).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}