wasm_pkg_client/oci/
config.rs

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