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#[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 _ => "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 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(®istry);
228 reg_conf
229 .set_backend_config("oci", &config)
230 .expect("Unable to set config");
231
232 let reg_conf = conf.registry_config(®istry).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}