Skip to main content

zlayer_core/auth/
resolver.rs

1//! Authentication resolver for OCI registries
2//!
3//! This module provides flexible authentication resolution supporting multiple sources
4//! and per-registry configuration.
5//!
6//! The wire DTOs (`AuthSource`, `AuthConfig`, `RegistryAuthConfig`) now live in
7//! `zlayer-types::auth` and are re-exported here so existing
8//! `zlayer_core::auth::AuthSource` (etc.) import paths keep working.
9
10pub use zlayer_types::auth::{AuthConfig, AuthSource, RegistryAuthConfig};
11
12use super::DockerConfigAuth;
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16/// Authentication resolver that converts `AuthConfig` to `oci_client` `RegistryAuth`
17pub struct AuthResolver {
18    config: AuthConfig,
19    docker_config: Option<DockerConfigAuth>,
20    registry_map: HashMap<String, AuthSource>,
21}
22
23impl AuthResolver {
24    /// Create a new authentication resolver
25    #[must_use]
26    pub fn new(config: AuthConfig) -> Self {
27        // Build a map for fast registry lookups
28        let registry_map: HashMap<String, AuthSource> = config
29            .registries
30            .iter()
31            .map(|r| (r.registry.clone(), r.source.clone()))
32            .collect();
33
34        // Load Docker config if any source uses DockerConfig
35        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    /// Resolve authentication for an image reference
54    ///
55    /// Extracts the registry from the image reference and returns the appropriate
56    /// `oci_client::secrets::RegistryAuth`.
57    #[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(&registry)
63            .unwrap_or(&self.config.default);
64
65        self.resolve_source(source, &registry)
66    }
67
68    /// Return the `AuthSource` that would be used for the given registry hostname.
69    ///
70    /// Looks up the per-registry map first, falling back to the default source.
71    #[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    /// Resolve a specific `AuthSource` to `RegistryAuth`.
79    ///
80    /// This is the synchronous resolution path. `AuthSource::SecretStore`
81    /// cannot be resolved synchronously and returns `Anonymous` with a
82    /// warning log.
83    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                // Fallback to anonymous if no credentials found
102                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    /// Extract registry hostname from image reference
129    ///
130    /// Examples:
131    /// - "ubuntu:latest" -> "docker.io"
132    /// - "ghcr.io/owner/repo:tag" -> "ghcr.io"
133    /// - "localhost:5000/image" -> "localhost:5000"
134    fn extract_registry(image: &str) -> String {
135        // Remove digest if present
136        let image_without_digest = image.split('@').next().unwrap_or(image);
137
138        // Split by '/'
139        let parts: Vec<&str> = image_without_digest.split('/').collect();
140
141        // If there's no '/', it's just an image name, assume Docker Hub
142        if parts.len() == 1 {
143            return "docker.io".to_string();
144        }
145
146        // Check if first part looks like a hostname (contains '.' or ':' or is 'localhost')
147        let first_part = parts[0];
148        if first_part.contains('.') || first_part.contains(':') || first_part == "localhost" {
149            first_part.to_string()
150        } else {
151            // No explicit registry (e.g., "library/ubuntu"), assume Docker Hub
152            "docker.io".to_string()
153        }
154    }
155
156    /// Load Docker config from path or default location
157    fn load_docker_config(path: Option<&PathBuf>) -> Option<DockerConfigAuth> {
158        let config = if let Some(path) = path {
159            DockerConfigAuth::load_from_path(path).ok()
160        } else {
161            DockerConfigAuth::load().ok()
162        };
163
164        if config.is_none() {
165            tracing::debug!("Failed to load Docker config, using anonymous auth as fallback");
166        }
167
168        config
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_extract_registry() {
178        assert_eq!(AuthResolver::extract_registry("ubuntu"), "docker.io");
179        assert_eq!(AuthResolver::extract_registry("ubuntu:latest"), "docker.io");
180        assert_eq!(
181            AuthResolver::extract_registry("library/ubuntu"),
182            "docker.io"
183        );
184        assert_eq!(
185            AuthResolver::extract_registry("ghcr.io/owner/repo"),
186            "ghcr.io"
187        );
188        assert_eq!(
189            AuthResolver::extract_registry("ghcr.io/owner/repo:tag"),
190            "ghcr.io"
191        );
192        assert_eq!(
193            AuthResolver::extract_registry("localhost:5000/image"),
194            "localhost:5000"
195        );
196        assert_eq!(
197            AuthResolver::extract_registry("myregistry.com/path/to/image:v1.0"),
198            "myregistry.com"
199        );
200    }
201
202    #[test]
203    fn test_anonymous_auth() {
204        let config = AuthConfig {
205            default: AuthSource::Anonymous,
206            ..Default::default()
207        };
208
209        let resolver = AuthResolver::new(config);
210        let auth = resolver.resolve("ubuntu:latest");
211
212        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
213    }
214
215    #[test]
216    fn test_basic_auth() {
217        let config = AuthConfig {
218            default: AuthSource::Basic {
219                username: "user".to_string(),
220                password: "pass".to_string(),
221            },
222            ..Default::default()
223        };
224
225        let resolver = AuthResolver::new(config);
226        let auth = resolver.resolve("ubuntu:latest");
227
228        match auth {
229            oci_client::secrets::RegistryAuth::Basic(username, password) => {
230                assert_eq!(username, "user");
231                assert_eq!(password, "pass");
232            }
233            _ => panic!("Expected Basic auth"),
234        }
235    }
236
237    #[test]
238    fn test_per_registry_auth() {
239        let config = AuthConfig {
240            registries: vec![RegistryAuthConfig {
241                registry: "ghcr.io".to_string(),
242                source: AuthSource::Basic {
243                    username: "ghcr_user".to_string(),
244                    password: "ghcr_pass".to_string(),
245                },
246            }],
247            default: AuthSource::Anonymous,
248            ..Default::default()
249        };
250
251        let resolver = AuthResolver::new(config);
252
253        // Should use specific auth for ghcr.io
254        let auth = resolver.resolve("ghcr.io/owner/repo:tag");
255        match auth {
256            oci_client::secrets::RegistryAuth::Basic(username, password) => {
257                assert_eq!(username, "ghcr_user");
258                assert_eq!(password, "ghcr_pass");
259            }
260            _ => panic!("Expected Basic auth for ghcr.io"),
261        }
262
263        // Should use default (anonymous) for docker.io
264        let auth = resolver.resolve("ubuntu:latest");
265        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
266    }
267
268    #[test]
269    fn test_env_var_auth() {
270        std::env::set_var("TEST_USERNAME", "env_user");
271        std::env::set_var("TEST_PASSWORD", "env_pass");
272
273        let config = AuthConfig {
274            default: AuthSource::EnvVar {
275                username_var: "TEST_USERNAME".to_string(),
276                password_var: "TEST_PASSWORD".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, "env_user");
287                assert_eq!(password, "env_pass");
288            }
289            _ => panic!("Expected Basic auth from env vars"),
290        }
291
292        std::env::remove_var("TEST_USERNAME");
293        std::env::remove_var("TEST_PASSWORD");
294    }
295
296    #[test]
297    fn test_env_var_auth_fallback() {
298        // Test that missing env vars fall back to anonymous
299        let config = AuthConfig {
300            default: AuthSource::EnvVar {
301                username_var: "NONEXISTENT_USER".to_string(),
302                password_var: "NONEXISTENT_PASS".to_string(),
303            },
304            ..Default::default()
305        };
306
307        let resolver = AuthResolver::new(config);
308        let auth = resolver.resolve("ubuntu:latest");
309
310        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
311    }
312
313    #[test]
314    fn test_secret_store_sync_fallback_returns_anonymous() {
315        let config = AuthConfig {
316            registries: vec![RegistryAuthConfig {
317                registry: "private.registry.io".to_string(),
318                source: AuthSource::SecretStore {
319                    credential_id: "cred-uuid-123".to_string(),
320                },
321            }],
322            default: AuthSource::Anonymous,
323            ..Default::default()
324        };
325
326        let resolver = AuthResolver::new(config);
327
328        // The sync path cannot resolve SecretStore and must return Anonymous
329        let auth = resolver.resolve("private.registry.io/image:latest");
330        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
331
332        // The default source should still work normally
333        let auth = resolver.resolve("ubuntu:latest");
334        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
335    }
336
337    #[test]
338    fn test_source_for_registry_returns_correct_source() {
339        let config = AuthConfig {
340            registries: vec![RegistryAuthConfig {
341                registry: "ghcr.io".to_string(),
342                source: AuthSource::Basic {
343                    username: "user".to_string(),
344                    password: "pass".to_string(),
345                },
346            }],
347            default: AuthSource::Anonymous,
348            ..Default::default()
349        };
350
351        let resolver = AuthResolver::new(config);
352
353        // Known registry returns its configured source
354        let source = resolver.source_for_registry("ghcr.io");
355        assert!(matches!(source, AuthSource::Basic { .. }));
356
357        // Unknown registry returns the default
358        let source = resolver.source_for_registry("docker.io");
359        assert!(matches!(source, AuthSource::Anonymous));
360    }
361
362    #[test]
363    fn test_secret_store_serde_roundtrip() {
364        let source = AuthSource::SecretStore {
365            credential_id: "abc-123".to_string(),
366        };
367        let json = serde_json::to_string(&source).unwrap();
368        let parsed: AuthSource = serde_json::from_str(&json).unwrap();
369        assert_eq!(source, parsed);
370    }
371}