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
6use super::DockerConfigAuth;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11/// Authentication source configuration
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum AuthSource {
15    /// No authentication
16    #[default]
17    Anonymous,
18
19    /// Basic authentication with username and password
20    Basic { username: String, password: String },
21
22    /// Load from Docker config.json
23    DockerConfig,
24
25    /// Load from environment variables
26    EnvVar {
27        username_var: String,
28        password_var: String,
29    },
30
31    /// Look up credentials from the `RegistryCredentialStore` by id.
32    /// Requires the async resolver -- the sync path returns `Anonymous` with
33    /// a warning log.
34    SecretStore { credential_id: String },
35}
36
37/// Per-registry authentication configuration
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct RegistryAuthConfig {
40    /// Registry hostname (e.g., "docker.io", "ghcr.io")
41    pub registry: String,
42
43    /// Authentication source for this registry
44    pub source: AuthSource,
45}
46
47/// Global authentication configuration
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct AuthConfig {
50    /// Per-registry authentication overrides
51    #[serde(default)]
52    pub registries: Vec<RegistryAuthConfig>,
53
54    /// Default authentication source for registries not in the list
55    #[serde(default)]
56    pub default: AuthSource,
57
58    /// Custom path to Docker config.json (if not using default)
59    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
72/// Authentication resolver that converts `AuthConfig` to `oci_client` `RegistryAuth`
73pub struct AuthResolver {
74    config: AuthConfig,
75    docker_config: Option<DockerConfigAuth>,
76    registry_map: HashMap<String, AuthSource>,
77}
78
79impl AuthResolver {
80    /// Create a new authentication resolver
81    #[must_use]
82    pub fn new(config: AuthConfig) -> Self {
83        // Build a map for fast registry lookups
84        let registry_map: HashMap<String, AuthSource> = config
85            .registries
86            .iter()
87            .map(|r| (r.registry.clone(), r.source.clone()))
88            .collect();
89
90        // Load Docker config if any source uses DockerConfig
91        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    /// Resolve authentication for an image reference
110    ///
111    /// Extracts the registry from the image reference and returns the appropriate
112    /// `oci_client::secrets::RegistryAuth`.
113    #[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(&registry)
119            .unwrap_or(&self.config.default);
120
121        self.resolve_source(source, &registry)
122    }
123
124    /// Return the `AuthSource` that would be used for the given registry hostname.
125    ///
126    /// Looks up the per-registry map first, falling back to the default source.
127    #[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    /// Resolve a specific `AuthSource` to `RegistryAuth`.
135    ///
136    /// This is the synchronous resolution path. `AuthSource::SecretStore`
137    /// cannot be resolved synchronously and returns `Anonymous` with a
138    /// warning log.
139    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                // Fallback to anonymous if no credentials found
158                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    /// Extract registry hostname from image reference
185    ///
186    /// Examples:
187    /// - "ubuntu:latest" -> "docker.io"
188    /// - "ghcr.io/owner/repo:tag" -> "ghcr.io"
189    /// - "localhost:5000/image" -> "localhost:5000"
190    fn extract_registry(image: &str) -> String {
191        // Remove digest if present
192        let image_without_digest = image.split('@').next().unwrap_or(image);
193
194        // Split by '/'
195        let parts: Vec<&str> = image_without_digest.split('/').collect();
196
197        // If there's no '/', it's just an image name, assume Docker Hub
198        if parts.len() == 1 {
199            return "docker.io".to_string();
200        }
201
202        // Check if first part looks like a hostname (contains '.' or ':' or is 'localhost')
203        let first_part = parts[0];
204        if first_part.contains('.') || first_part.contains(':') || first_part == "localhost" {
205            first_part.to_string()
206        } else {
207            // No explicit registry (e.g., "library/ubuntu"), assume Docker Hub
208            "docker.io".to_string()
209        }
210    }
211
212    /// Load Docker config from path or default location
213    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        // Should use specific auth for ghcr.io
310        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        // Should use default (anonymous) for docker.io
320        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        // Test that missing env vars fall back to anonymous
355        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        // The sync path cannot resolve SecretStore and must return Anonymous
385        let auth = resolver.resolve("private.registry.io/image:latest");
386        assert!(matches!(auth, oci_client::secrets::RegistryAuth::Anonymous));
387
388        // The default source should still work normally
389        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        // Known registry returns its configured source
410        let source = resolver.source_for_registry("ghcr.io");
411        assert!(matches!(source, AuthSource::Basic { .. }));
412
413        // Unknown registry returns the default
414        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}