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    /// Normalize a registry hostname for case-insensitive, scheme/slash
129    /// insensitive matching.
130    ///
131    /// Strips a leading `http://` / `https://`, trims trailing `/`, and
132    /// lowercases. This lets a credential stored under `https://ghcr.io/`
133    /// (as a user might type it into `zlayer login`) match an image whose
134    /// extracted registry is `ghcr.io`.
135    ///
136    /// Examples:
137    /// - `https://ghcr.io/` -> `ghcr.io`
138    /// - `GHCR.IO` -> `ghcr.io`
139    /// - `localhost:5000` -> `localhost:5000`
140    #[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    /// Extract registry hostname from image reference
151    ///
152    /// Examples:
153    /// - "ubuntu:latest" -> "docker.io"
154    /// - "ghcr.io/owner/repo:tag" -> "ghcr.io"
155    /// - "localhost:5000/image" -> "localhost:5000"
156    #[must_use]
157    pub fn extract_registry(image: &str) -> String {
158        // Remove digest if present
159        let image_without_digest = image.split('@').next().unwrap_or(image);
160
161        // Split by '/'
162        let parts: Vec<&str> = image_without_digest.split('/').collect();
163
164        // If there's no '/', it's just an image name, assume Docker Hub
165        if parts.len() == 1 {
166            return "docker.io".to_string();
167        }
168
169        // Check if first part looks like a hostname (contains '.' or ':' or is 'localhost')
170        let first_part = parts[0];
171        if first_part.contains('.') || first_part.contains(':') || first_part == "localhost" {
172            first_part.to_string()
173        } else {
174            // No explicit registry (e.g., "library/ubuntu"), assume Docker Hub
175            "docker.io".to_string()
176        }
177    }
178
179    /// Load Docker config from path or default location
180    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        // Should use specific auth for ghcr.io
296        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        // Should use default (anonymous) for docker.io
306        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        // Test that missing env vars fall back to anonymous
341        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        // The sync path cannot resolve SecretStore and must return Anonymous
371        let auth = resolver.resolve("private.registry.io/image:latest");
372        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
373
374        // The default source should still work normally
375        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        // Known registry returns its configured source
396        let source = resolver.source_for_registry("ghcr.io");
397        assert!(matches!(source, AuthSource::Basic { .. }));
398
399        // Unknown registry returns the default
400        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}