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