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