1pub use zlayer_types::auth::{AuthConfig, AuthSource, RegistryAuthConfig};
11
12use super::DockerConfigAuth;
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16pub struct AuthResolver {
18 config: AuthConfig,
19 docker_config: Option<DockerConfigAuth>,
20 registry_map: HashMap<String, AuthSource>,
21}
22
23impl AuthResolver {
24 #[must_use]
26 pub fn new(config: AuthConfig) -> Self {
27 let registry_map: HashMap<String, AuthSource> = config
29 .registries
30 .iter()
31 .map(|r| (r.registry.clone(), r.source.clone()))
32 .collect();
33
34 let needs_docker_config = config.default == AuthSource::DockerConfig
36 || registry_map
37 .values()
38 .any(|s| matches!(s, AuthSource::DockerConfig));
39
40 let docker_config = if needs_docker_config {
41 Self::load_docker_config(config.docker_config_path.as_ref())
42 } else {
43 None
44 };
45
46 Self {
47 config,
48 docker_config,
49 registry_map,
50 }
51 }
52
53 #[must_use]
58 pub fn resolve(&self, image: &str) -> oci_client::secrets::RegistryAuth {
59 let registry = Self::extract_registry(image);
60 let source = self
61 .registry_map
62 .get(®istry)
63 .unwrap_or(&self.config.default);
64
65 self.resolve_source(source, ®istry)
66 }
67
68 #[must_use]
72 pub fn source_for_registry(&self, registry: &str) -> &AuthSource {
73 self.registry_map
74 .get(registry)
75 .unwrap_or(&self.config.default)
76 }
77
78 pub fn resolve_source(
84 &self,
85 source: &AuthSource,
86 registry: &str,
87 ) -> oci_client::secrets::RegistryAuth {
88 match source {
89 AuthSource::Anonymous => oci_client::secrets::RegistryAuth::Anonymous,
90
91 AuthSource::Basic { username, password } => {
92 oci_client::secrets::RegistryAuth::Basic(username.clone(), password.clone())
93 }
94
95 AuthSource::DockerConfig => {
96 if let Some(ref docker_config) = self.docker_config {
97 if let Some((username, password)) = docker_config.get_credentials(registry) {
98 return oci_client::secrets::RegistryAuth::Basic(username, password);
99 }
100 }
101 oci_client::secrets::RegistryAuth::Anonymous
103 }
104
105 AuthSource::EnvVar {
106 username_var,
107 password_var,
108 } => {
109 let username = std::env::var(username_var).unwrap_or_default();
110 let password = std::env::var(password_var).unwrap_or_default();
111
112 if !username.is_empty() && !password.is_empty() {
113 oci_client::secrets::RegistryAuth::Basic(username, password)
114 } else {
115 oci_client::secrets::RegistryAuth::Anonymous
116 }
117 }
118
119 AuthSource::SecretStore { .. } => {
120 tracing::warn!(
121 "SecretStore auth source requires async resolver; returning Anonymous"
122 );
123 oci_client::secrets::RegistryAuth::Anonymous
124 }
125 }
126 }
127
128 #[must_use]
141 pub fn normalize_registry(registry: &str) -> String {
142 let s = registry.trim();
143 let s = s
144 .strip_prefix("https://")
145 .or_else(|| s.strip_prefix("http://"))
146 .unwrap_or(s);
147 s.trim_end_matches('/').to_ascii_lowercase()
148 }
149
150 #[must_use]
157 pub fn extract_registry(image: &str) -> String {
158 let image_without_digest = image.split('@').next().unwrap_or(image);
160
161 let parts: Vec<&str> = image_without_digest.split('/').collect();
163
164 if parts.len() == 1 {
166 return "docker.io".to_string();
167 }
168
169 let first_part = parts[0];
171 if first_part.contains('.') || first_part.contains(':') || first_part == "localhost" {
172 first_part.to_string()
173 } else {
174 "docker.io".to_string()
176 }
177 }
178
179 fn load_docker_config(path: Option<&PathBuf>) -> Option<DockerConfigAuth> {
181 let config = if let Some(path) = path {
182 DockerConfigAuth::load_from_path(path).ok()
183 } else {
184 DockerConfigAuth::load().ok()
185 };
186
187 if config.is_none() {
188 tracing::debug!("Failed to load Docker config, using anonymous auth as fallback");
189 }
190
191 config
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_extract_registry() {
201 assert_eq!(AuthResolver::extract_registry("ubuntu"), "docker.io");
202 assert_eq!(AuthResolver::extract_registry("ubuntu:latest"), "docker.io");
203 assert_eq!(
204 AuthResolver::extract_registry("library/ubuntu"),
205 "docker.io"
206 );
207 assert_eq!(
208 AuthResolver::extract_registry("ghcr.io/owner/repo"),
209 "ghcr.io"
210 );
211 assert_eq!(
212 AuthResolver::extract_registry("ghcr.io/owner/repo:tag"),
213 "ghcr.io"
214 );
215 assert_eq!(
216 AuthResolver::extract_registry("localhost:5000/image"),
217 "localhost:5000"
218 );
219 assert_eq!(
220 AuthResolver::extract_registry("myregistry.com/path/to/image:v1.0"),
221 "myregistry.com"
222 );
223 }
224
225 #[test]
226 fn test_normalize_registry() {
227 assert_eq!(AuthResolver::normalize_registry("ghcr.io"), "ghcr.io");
228 assert_eq!(AuthResolver::normalize_registry("GHCR.IO"), "ghcr.io");
229 assert_eq!(
230 AuthResolver::normalize_registry("https://ghcr.io"),
231 "ghcr.io"
232 );
233 assert_eq!(
234 AuthResolver::normalize_registry("https://ghcr.io/"),
235 "ghcr.io"
236 );
237 assert_eq!(
238 AuthResolver::normalize_registry("http://localhost:5000/"),
239 "localhost:5000"
240 );
241 assert_eq!(AuthResolver::normalize_registry(" ghcr.io "), "ghcr.io");
242 }
243
244 #[test]
245 fn test_anonymous_auth() {
246 let config = AuthConfig {
247 default: AuthSource::Anonymous,
248 ..Default::default()
249 };
250
251 let resolver = AuthResolver::new(config);
252 let auth = resolver.resolve("ubuntu:latest");
253
254 assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
255 }
256
257 #[test]
258 fn test_basic_auth() {
259 let config = AuthConfig {
260 default: AuthSource::Basic {
261 username: "user".to_string(),
262 password: "pass".to_string(),
263 },
264 ..Default::default()
265 };
266
267 let resolver = AuthResolver::new(config);
268 let auth = resolver.resolve("ubuntu:latest");
269
270 match auth {
271 oci_client::secrets::RegistryAuth::Basic(username, password) => {
272 assert_eq!(username, "user");
273 assert_eq!(password, "pass");
274 }
275 _ => panic!("Expected Basic auth"),
276 }
277 }
278
279 #[test]
280 fn test_per_registry_auth() {
281 let config = AuthConfig {
282 registries: vec![RegistryAuthConfig {
283 registry: "ghcr.io".to_string(),
284 source: AuthSource::Basic {
285 username: "ghcr_user".to_string(),
286 password: "ghcr_pass".to_string(),
287 },
288 }],
289 default: AuthSource::Anonymous,
290 ..Default::default()
291 };
292
293 let resolver = AuthResolver::new(config);
294
295 let auth = resolver.resolve("ghcr.io/owner/repo:tag");
297 match auth {
298 oci_client::secrets::RegistryAuth::Basic(username, password) => {
299 assert_eq!(username, "ghcr_user");
300 assert_eq!(password, "ghcr_pass");
301 }
302 _ => panic!("Expected Basic auth for ghcr.io"),
303 }
304
305 let auth = resolver.resolve("ubuntu:latest");
307 assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
308 }
309
310 #[test]
311 fn test_env_var_auth() {
312 std::env::set_var("TEST_USERNAME", "env_user");
313 std::env::set_var("TEST_PASSWORD", "env_pass");
314
315 let config = AuthConfig {
316 default: AuthSource::EnvVar {
317 username_var: "TEST_USERNAME".to_string(),
318 password_var: "TEST_PASSWORD".to_string(),
319 },
320 ..Default::default()
321 };
322
323 let resolver = AuthResolver::new(config);
324 let auth = resolver.resolve("ubuntu:latest");
325
326 match auth {
327 oci_client::secrets::RegistryAuth::Basic(username, password) => {
328 assert_eq!(username, "env_user");
329 assert_eq!(password, "env_pass");
330 }
331 _ => panic!("Expected Basic auth from env vars"),
332 }
333
334 std::env::remove_var("TEST_USERNAME");
335 std::env::remove_var("TEST_PASSWORD");
336 }
337
338 #[test]
339 fn test_env_var_auth_fallback() {
340 let config = AuthConfig {
342 default: AuthSource::EnvVar {
343 username_var: "NONEXISTENT_USER".to_string(),
344 password_var: "NONEXISTENT_PASS".to_string(),
345 },
346 ..Default::default()
347 };
348
349 let resolver = AuthResolver::new(config);
350 let auth = resolver.resolve("ubuntu:latest");
351
352 assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
353 }
354
355 #[test]
356 fn test_secret_store_sync_fallback_returns_anonymous() {
357 let config = AuthConfig {
358 registries: vec![RegistryAuthConfig {
359 registry: "private.registry.io".to_string(),
360 source: AuthSource::SecretStore {
361 credential_id: "cred-uuid-123".to_string(),
362 },
363 }],
364 default: AuthSource::Anonymous,
365 ..Default::default()
366 };
367
368 let resolver = AuthResolver::new(config);
369
370 let auth = resolver.resolve("private.registry.io/image:latest");
372 assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
373
374 let auth = resolver.resolve("ubuntu:latest");
376 assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
377 }
378
379 #[test]
380 fn test_source_for_registry_returns_correct_source() {
381 let config = AuthConfig {
382 registries: vec![RegistryAuthConfig {
383 registry: "ghcr.io".to_string(),
384 source: AuthSource::Basic {
385 username: "user".to_string(),
386 password: "pass".to_string(),
387 },
388 }],
389 default: AuthSource::Anonymous,
390 ..Default::default()
391 };
392
393 let resolver = AuthResolver::new(config);
394
395 let source = resolver.source_for_registry("ghcr.io");
397 assert!(matches!(source, AuthSource::Basic { .. }));
398
399 let source = resolver.source_for_registry("docker.io");
401 assert!(matches!(source, AuthSource::Anonymous));
402 }
403
404 #[test]
405 fn test_secret_store_serde_roundtrip() {
406 let source = AuthSource::SecretStore {
407 credential_id: "abc-123".to_string(),
408 };
409 let json = serde_json::to_string(&source).unwrap();
410 let parsed: AuthSource = serde_json::from_str(&json).unwrap();
411 assert_eq!(source, parsed);
412 }
413}