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