Skip to main content

oci_api/auth/
config_loader.rs

1//! INI format OCI configuration file loader
2//!
3//! Reads OCI configuration from file path or INI content string.
4
5use crate::auth::key_loader::KeyLoader;
6use crate::error::{Error, Result};
7use ini::{Ini, Properties};
8use std::path::Path;
9
10/// Loaded configuration data
11#[derive(Debug)]
12pub struct LoadedConfig {
13    pub user_id: String,
14    pub tenancy_id: String,
15    pub region: String,
16    pub fingerprint: String,
17    pub private_key: String,
18}
19
20/// OCI configuration file loader
21pub struct ConfigLoader;
22
23impl ConfigLoader {
24    /// Load configuration from OCI_CONFIG environment variable value
25    ///
26    /// Automatically detects whether the value is a file path or INI content
27    ///
28    /// # Arguments
29    /// * `config_value` - Value from OCI_CONFIG environment variable (file path or INI content)
30    /// * `profile` - Profile name (default: "DEFAULT")
31    pub fn load_from_env_var(config_value: &str, profile: Option<&str>) -> Result<LoadedConfig> {
32        // Check if it's a file path
33        let path = Path::new(config_value);
34        if path.exists() {
35            Self::load_from_file(path, profile)
36        } else {
37            // Treat as INI content
38            Self::load_from_ini_content(config_value, profile)
39        }
40    }
41
42    /// Load configuration from INI content string
43    ///
44    /// # Arguments
45    /// * `ini_content` - INI format configuration string
46    /// * `profile` - Profile name (default: "DEFAULT")
47    pub fn load_from_ini_content(ini_content: &str, profile: Option<&str>) -> Result<LoadedConfig> {
48        let profile_name = profile.unwrap_or("DEFAULT");
49
50        // Parse INI content
51        let ini = Ini::load_from_str(ini_content)
52            .map_err(|e| Error::IniError(format!("Failed to parse INI content: {e}")))?;
53
54        // Find profile section
55        let section = ini.section(Some(profile_name)).ok_or_else(|| {
56            Error::ConfigError(format!("Profile '{profile_name}' not found in INI content"))
57        })?;
58
59        // Read and build config
60        Self::build_config_from_section(section)
61    }
62
63    /// Load configuration from file path
64    ///
65    /// # Arguments
66    /// * `path` - Configuration file path
67    /// * `profile` - Profile name (default: "DEFAULT")
68    pub fn load_from_file(path: &Path, profile: Option<&str>) -> Result<LoadedConfig> {
69        let profile_name = profile.unwrap_or("DEFAULT");
70
71        // Parse INI file
72        let ini = Ini::load_from_file(path)
73            .map_err(|e| Error::IniError(format!("Failed to load INI file: {e}")))?;
74
75        // Find profile section
76        let section = ini
77            .section(Some(profile_name))
78            .ok_or_else(|| Error::ConfigError(format!("Profile '{profile_name}' not found")))?;
79
80        // Read and build config
81        Self::build_config_from_section(section)
82    }
83
84    /// Build LoadedConfig from INI section
85    fn build_config_from_section(section: &Properties) -> Result<LoadedConfig> {
86        // Read required fields
87        let user_id = section
88            .get("user")
89            .ok_or_else(|| Error::ConfigError("user field not found in config".to_string()))?
90            .to_string();
91
92        let tenancy_id = section
93            .get("tenancy")
94            .ok_or_else(|| Error::ConfigError("tenancy field not found in config".to_string()))?
95            .to_string();
96
97        let region = section
98            .get("region")
99            .ok_or_else(|| Error::ConfigError("region field not found in config".to_string()))?
100            .to_string();
101
102        let fingerprint = section
103            .get("fingerprint")
104            .ok_or_else(|| Error::ConfigError("fingerprint field not found in config".to_string()))?
105            .to_string();
106
107        // key_file is required for traditional config file loading
108        // If key_file is missing, the caller must provide private_key separately
109        let key_file = section
110            .get("key_file")
111            .ok_or_else(|| Error::ConfigError("key_file field not found in config".to_string()))?;
112
113        // Load private key from key_file path
114        // Note: key_file in OCI config typically uses paths like ~/...
115        // We expand ~ to home directory for convenience
116        let key_path = if key_file.starts_with("~/") {
117            let home = std::env::var("HOME").map_err(|_| {
118                Error::EnvError("Cannot find HOME environment variable".to_string())
119            })?;
120            key_file.replacen("~", &home, 1)
121        } else {
122            key_file.to_string()
123        };
124
125        let private_key = KeyLoader::load(&key_path)?;
126
127        Ok(LoadedConfig {
128            user_id,
129            tenancy_id,
130            region,
131            fingerprint,
132            private_key,
133        })
134    }
135
136    /// Load partial configuration from OCI_CONFIG environment variable
137    /// Returns only the fields present in the config file
138    /// Used by from_env() to get base values before applying environment variable overrides
139    pub(crate) fn load_partial_from_env_var(config_value: &str) -> Result<PartialOciConfig> {
140        let ini = if std::path::Path::new(config_value).exists() {
141            // It's a file path
142            Ini::load_from_file(config_value)
143                .map_err(|e| Error::ConfigError(format!("Failed to load config file: {e}")))?
144        } else {
145            // It's INI content
146            Ini::load_from_str(config_value)
147                .map_err(|e| Error::ConfigError(format!("Failed to parse INI content: {e}")))?
148        };
149
150        let profile_name = "DEFAULT";
151        let section = ini
152            .section(Some(profile_name))
153            .ok_or_else(|| Error::ConfigError(format!("Profile '{profile_name}' not found")))?;
154
155        // Extract only the fields that are present
156        Ok(PartialOciConfig {
157            user_id: section.get("user").map(|s| s.to_string()),
158            tenancy_id: section.get("tenancy").map(|s| s.to_string()),
159            region: section.get("region").map(|s| s.to_string()),
160            fingerprint: section.get("fingerprint").map(|s| s.to_string()),
161        })
162    }
163}
164
165/// Partial OCI configuration with optional fields
166/// Used when loading from OCI_CONFIG environment variable
167#[derive(Debug, Default)]
168pub(crate) struct PartialOciConfig {
169    pub user_id: Option<String>,
170    pub tenancy_id: Option<String>,
171    pub region: Option<String>,
172    pub fingerprint: Option<String>,
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::io::Write;
179    use tempfile::NamedTempFile;
180
181    #[test]
182    fn test_load_from_file_success() {
183        // Create temporary INI file and key file
184        let mut key_file = NamedTempFile::new().unwrap();
185        let key_content = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----\n";
186        key_file.write_all(key_content.as_bytes()).unwrap();
187
188        let mut ini_file = NamedTempFile::new().unwrap();
189        let ini_content = format!(
190            r#"
191[DEFAULT]
192user=ocid1.user.test
193tenancy=ocid1.tenancy.test
194region=ap-seoul-1
195fingerprint=aa:bb:cc:dd:ee:ff
196key_file={}
197"#,
198            key_file.path().to_str().unwrap()
199        );
200        ini_file.write_all(ini_content.as_bytes()).unwrap();
201
202        let result = ConfigLoader::load_from_file(ini_file.path(), None);
203        assert!(result.is_ok());
204
205        let config = result.unwrap();
206        assert_eq!(config.user_id, "ocid1.user.test");
207        assert_eq!(config.tenancy_id, "ocid1.tenancy.test");
208        assert_eq!(config.region, "ap-seoul-1");
209        assert_eq!(config.fingerprint, "aa:bb:cc:dd:ee:ff");
210        assert!(config.private_key.contains("BEGIN RSA PRIVATE KEY"));
211    }
212
213    #[test]
214    fn test_load_from_file_missing_field() {
215        let mut ini_file = NamedTempFile::new().unwrap();
216        let ini_content = r#"
217[DEFAULT]
218user=ocid1.user.test
219tenancy=ocid1.tenancy.test
220region=ap-seoul-1
221"#;
222        ini_file.write_all(ini_content.as_bytes()).unwrap();
223
224        let result = ConfigLoader::load_from_file(ini_file.path(), None);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn test_load_from_file_profile_not_found() {
230        let mut ini_file = NamedTempFile::new().unwrap();
231        let ini_content = r#"
232[DEFAULT]
233user=ocid1.user.test
234"#;
235        ini_file.write_all(ini_content.as_bytes()).unwrap();
236
237        let result = ConfigLoader::load_from_file(ini_file.path(), Some("NONEXISTENT"));
238        assert!(result.is_err());
239        match result.unwrap_err() {
240            Error::ConfigError(msg) => assert!(msg.contains("NONEXISTENT")),
241            _ => panic!("Expected ConfigError"),
242        }
243    }
244}