Skip to main content

oci_api/client/
http.rs

1//! OCI HTTP client
2//!
3//! OCI API HTTP client with custom request signing
4
5use crate::auth::config_loader::ConfigLoader;
6use crate::auth::key_loader::KeyLoader;
7use crate::auth::providers::{
8    ApiKeyAuthProvider, DEFAULT_METADATA_BASE_URL, DEFAULT_REALM_DOMAIN_COMPONENT,
9    DynOciAuthProvider, InstancePrincipalAuthProvider, InstancePrincipalConfig, MetadataRegionInfo,
10};
11use crate::client::request_executor::RequestExecutor;
12use crate::client::signer::OciSigner;
13use crate::error::{Error, Result};
14use crate::services::email::EmailDelivery;
15use crate::services::keys::KeysClient;
16use crate::services::object_storage::ObjectStorage;
17use crate::services::vault::VaultSecretsClient;
18use reqwest::{Client, blocking::Client as BlockingClient};
19use std::env;
20use std::sync::Arc;
21use std::time::Duration;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AuthMode {
25    ApiKey,
26    InstancePrincipal,
27}
28
29const OCI_METADATA_PROBE_TIMEOUT: Duration = Duration::from_millis(500);
30const OCI_METADATA_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(2);
31
32/// OCI HTTP client
33#[derive(Clone)]
34pub struct Oci {
35    /// HTTP client
36    client: Client,
37
38    /// Region
39    region: String,
40
41    /// Realm domain component
42    realm_domain_component: String,
43
44    /// Tenancy ID
45    tenancy_id: String,
46
47    /// Compartment ID
48    compartment_id: Option<String>,
49
50    /// Authentication mode
51    auth_mode: AuthMode,
52    /// API key signer for compatibility
53    signer: Option<OciSigner>,
54    /// Authentication provider
55    auth_provider: DynOciAuthProvider,
56}
57
58impl Default for Oci {
59    fn default() -> Self {
60        Self::from_env().expect("Failed to create OCI client from environment")
61    }
62}
63
64impl Oci {
65    /// Create new OCI client from environment variables
66    pub fn from_env() -> Result<Self> {
67        let auth_mode = Self::resolve_auth_mode_from_env()?;
68
69        match auth_mode {
70            AuthMode::ApiKey => Self::from_api_key_env(),
71            AuthMode::InstancePrincipal => Self::from_instance_principal_env(),
72        }
73    }
74
75    /// Resolve the OCI auth mode from environment variables and OCI metadata.
76    ///
77    /// Precedence:
78    /// 1. Explicit `OCI_AUTH_MODE` override when provided.
79    /// 2. OCI IMDS autodetection with a short timeout.
80    /// 3. API key fallback for non-OCI runtimes.
81    pub fn resolve_auth_mode_from_env() -> Result<AuthMode> {
82        if let Some(auth_mode) = Self::explicit_auth_mode_from_env()? {
83            return Ok(auth_mode);
84        }
85
86        if Self::autodetect_instance_principal()? {
87            return Ok(AuthMode::InstancePrincipal);
88        }
89
90        Ok(AuthMode::ApiKey)
91    }
92
93    fn from_api_key_env() -> Result<Self> {
94        // Step 1: Load partial configuration from OCI_CONFIG if available
95        let partial_config = if let Ok(config_value) = env::var("OCI_CONFIG") {
96            Some(ConfigLoader::load_partial_from_env_var(&config_value)?)
97        } else {
98            None
99        };
100
101        // Step 2: Merge with individual environment variables (highest priority)
102        let user_id = env::var("OCI_USER_ID")
103            .ok()
104            .or_else(|| partial_config.as_ref().and_then(|c| c.user_id.clone()))
105            .ok_or_else(|| {
106                Error::EnvError(
107                    "OCI_USER_ID must be set (either directly or via OCI_CONFIG)".to_string(),
108                )
109            })?;
110
111        let tenancy_id = env::var("OCI_TENANCY_ID")
112            .ok()
113            .or_else(|| partial_config.as_ref().and_then(|c| c.tenancy_id.clone()))
114            .ok_or_else(|| {
115                Error::EnvError(
116                    "OCI_TENANCY_ID must be set (either directly or via OCI_CONFIG)".to_string(),
117                )
118            })?;
119
120        let region = env::var("OCI_REGION")
121            .ok()
122            .or_else(|| partial_config.as_ref().and_then(|c| c.region.clone()))
123            .ok_or_else(|| {
124                Error::EnvError(
125                    "OCI_REGION must be set (either directly or via OCI_CONFIG)".to_string(),
126                )
127            })?;
128
129        let fingerprint = env::var("OCI_FINGERPRINT")
130            .ok()
131            .or_else(|| partial_config.as_ref().and_then(|c| c.fingerprint.clone()))
132            .ok_or_else(|| {
133                Error::EnvError(
134                    "OCI_FINGERPRINT must be set (either directly or via OCI_CONFIG)".to_string(),
135                )
136            })?;
137
138        // Step 3: Load private key
139        let private_key = if let Ok(key_input) = env::var("OCI_PRIVATE_KEY") {
140            KeyLoader::load(&key_input)?
141        } else if let Ok(config_value) = env::var("OCI_CONFIG") {
142            let full_config = ConfigLoader::load_from_env_var(&config_value, None)?;
143            full_config.private_key
144        } else {
145            return Err(Error::EnvError(
146                "OCI_PRIVATE_KEY must be set (or key_file must be in OCI_CONFIG)".to_string(),
147            ));
148        };
149
150        // Step 4: Optional compartment ID
151        let compartment_id = env::var("OCI_COMPARTMENT_ID").ok();
152
153        Self::builder()
154            .auth_mode(AuthMode::ApiKey)
155            .user_id(user_id)
156            .tenancy_id(tenancy_id)
157            .region(region)
158            .fingerprint(fingerprint)
159            .private_key(private_key)?
160            .compartment_id_opt(compartment_id)
161            .build()
162    }
163
164    fn from_instance_principal_env() -> Result<Self> {
165        let metadata_base_url = env::var("OCI_METADATA_BASE_URL").ok();
166        let metadata_client = Self::blocking_metadata_client(OCI_METADATA_BOOTSTRAP_TIMEOUT)?;
167        let metadata_region_info =
168            Self::metadata_region_info(&metadata_client, metadata_base_url.as_deref());
169
170        Self::from_instance_principal_env_with(
171            &metadata_client,
172            metadata_base_url,
173            metadata_region_info,
174        )
175    }
176
177    fn from_instance_principal_env_with(
178        metadata_client: &BlockingClient,
179        metadata_base_url: Option<String>,
180        metadata_region_info: Option<MetadataRegionInfo>,
181    ) -> Result<Self> {
182        let region = env::var("OCI_REGION")
183            .ok()
184            .or_else(|| {
185                metadata_region_info
186                    .as_ref()
187                    .map(|region_info| region_info.region_identifier.clone())
188            })
189            .ok_or_else(|| {
190                Error::EnvError(
191                    "OCI_REGION must be set or discoverable from OCI metadata when OCI_AUTH_MODE=instance_principal"
192                        .to_owned(),
193                )
194            })?;
195        let tenancy_id = env::var("OCI_TENANCY_ID")
196            .ok()
197            .or_else(|| {
198                InstancePrincipalAuthProvider::tenancy_id_from_metadata_certificate_blocking(
199                    &metadata_client,
200                    metadata_base_url
201                        .as_deref()
202                        .unwrap_or(DEFAULT_METADATA_BASE_URL),
203                )
204                .ok()
205            })
206            .ok_or_else(|| {
207                Error::EnvError(
208                    "OCI_TENANCY_ID must be set or discoverable from OCI metadata when OCI_AUTH_MODE=instance_principal"
209                        .to_owned(),
210                )
211            })?;
212        let compartment_id = env::var("OCI_COMPARTMENT_ID").ok();
213        let realm_domain_component = metadata_region_info
214            .as_ref()
215            .map(|region_info| region_info.realm_domain_component.clone())
216            .unwrap_or_else(|| DEFAULT_REALM_DOMAIN_COMPONENT.to_owned());
217
218        let mut builder = Self::builder()
219            .auth_mode(AuthMode::InstancePrincipal)
220            .region(region)
221            .realm_domain_component(realm_domain_component)
222            .tenancy_id(tenancy_id)
223            .compartment_id_opt(compartment_id);
224        if let Some(metadata_base_url) = metadata_base_url {
225            builder = builder.metadata_base_url(metadata_base_url);
226        }
227        builder.build()
228    }
229
230    fn explicit_auth_mode_from_env() -> Result<Option<AuthMode>> {
231        let Some(raw_auth_mode) = env::var("OCI_AUTH_MODE").ok() else {
232            return Ok(None);
233        };
234        let auth_mode = raw_auth_mode.trim();
235        if auth_mode.is_empty() {
236            return Ok(None);
237        }
238
239        match auth_mode {
240            "api_key" => Ok(Some(AuthMode::ApiKey)),
241            "instance_principal" => Ok(Some(AuthMode::InstancePrincipal)),
242            other => Err(Error::EnvError(format!(
243                "OCI_AUTH_MODE must be 'api_key' or 'instance_principal', got '{other}'"
244            ))),
245        }
246    }
247
248    fn autodetect_instance_principal() -> Result<bool> {
249        let metadata_client = Self::blocking_metadata_client(OCI_METADATA_PROBE_TIMEOUT)?;
250        let metadata_base_url = env::var("OCI_METADATA_BASE_URL").ok();
251        Ok(Self::metadata_region_info(&metadata_client, metadata_base_url.as_deref()).is_some())
252    }
253
254    fn metadata_region_info(
255        metadata_client: &BlockingClient,
256        metadata_base_url: Option<&str>,
257    ) -> Option<MetadataRegionInfo> {
258        InstancePrincipalAuthProvider::metadata_region_info_blocking(
259            metadata_client,
260            metadata_base_url.unwrap_or(DEFAULT_METADATA_BASE_URL),
261        )
262        .ok()
263    }
264
265    fn blocking_metadata_client(timeout: Duration) -> Result<BlockingClient> {
266        Ok(BlockingClient::builder()
267            .connect_timeout(timeout)
268            .timeout(timeout)
269            .build()?)
270    }
271
272    /// Start builder pattern
273    pub fn builder() -> OciBuilder {
274        OciBuilder::default()
275    }
276
277    /// Get request signer
278    pub fn signer(&self) -> &OciSigner {
279        self.signer
280            .as_ref()
281            .expect("Oci::signer() is only available in api_key mode")
282    }
283
284    /// Return HTTP client reference
285    pub fn client(&self) -> &Client {
286        &self.client
287    }
288
289    pub(crate) fn executor(&self) -> RequestExecutor {
290        RequestExecutor::new(self.client.clone(), Arc::clone(&self.auth_provider))
291    }
292
293    /// Return region
294    pub fn region(&self) -> &str {
295        &self.region
296    }
297
298    /// Return realm domain component
299    pub fn realm_domain(&self) -> &str {
300        &self.realm_domain_component
301    }
302
303    /// Return tenancy ID
304    pub fn tenancy_id(&self) -> &str {
305        &self.tenancy_id
306    }
307
308    /// Return compartment ID (defaults to tenancy_id if not set)
309    pub fn compartment_id(&self) -> &str {
310        self.compartment_id.as_ref().unwrap_or(&self.tenancy_id)
311    }
312
313    pub fn auth_mode(&self) -> AuthMode {
314        self.auth_mode
315    }
316
317    /// Create Email Delivery client
318    pub async fn email_delivery(&self) -> Result<EmailDelivery> {
319        EmailDelivery::new(self.clone()).await
320    }
321
322    /// Create Object Storage client
323    pub fn object_storage(&self, namespace: impl Into<String>) -> ObjectStorage {
324        ObjectStorage::new(self, namespace)
325    }
326
327    /// Create Vault Secrets client
328    pub fn vault(&self) -> VaultSecretsClient {
329        VaultSecretsClient::new(self)
330    }
331
332    /// Create Keys client
333    pub fn keys(&self, management_endpoint: impl Into<String>) -> KeysClient {
334        KeysClient::new(self, management_endpoint)
335    }
336}
337
338/// OCI client builder
339#[derive(Default)]
340pub struct OciBuilder {
341    user_id: Option<String>,
342    tenancy_id: Option<String>,
343    region: Option<String>,
344    realm_domain_component: Option<String>,
345    fingerprint: Option<String>,
346    private_key: Option<String>,
347    compartment_id: Option<String>,
348    auth_mode: AuthMode,
349    metadata_base_url: Option<String>,
350}
351
352impl OciBuilder {
353    /// Load configuration from OCI config file
354    pub fn config(mut self, path: impl AsRef<std::path::Path>) -> Result<Self> {
355        let loaded = ConfigLoader::load_from_file(path.as_ref(), Some("DEFAULT"))?;
356
357        self.user_id = Some(loaded.user_id);
358        self.tenancy_id = Some(loaded.tenancy_id);
359        self.region = Some(loaded.region);
360        self.fingerprint = Some(loaded.fingerprint);
361        self.private_key = Some(loaded.private_key);
362
363        Ok(self)
364    }
365
366    pub fn user_id(mut self, user_id: impl Into<String>) -> Self {
367        self.user_id = Some(user_id.into());
368        self
369    }
370
371    pub fn auth_mode(mut self, auth_mode: AuthMode) -> Self {
372        self.auth_mode = auth_mode;
373        self
374    }
375
376    pub fn tenancy_id(mut self, tenancy_id: impl Into<String>) -> Self {
377        self.tenancy_id = Some(tenancy_id.into());
378        self
379    }
380
381    pub fn region(mut self, region: impl Into<String>) -> Self {
382        self.region = Some(region.into());
383        self
384    }
385
386    pub fn realm_domain_component(mut self, realm_domain_component: impl Into<String>) -> Self {
387        self.realm_domain_component = Some(realm_domain_component.into());
388        self
389    }
390
391    pub fn fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
392        self.fingerprint = Some(fingerprint.into());
393        self
394    }
395
396    pub fn private_key(mut self, private_key: impl Into<String>) -> Result<Self> {
397        let key_input = private_key.into();
398        let loaded_key = KeyLoader::load(&key_input)?;
399        self.private_key = Some(loaded_key);
400        Ok(self)
401    }
402
403    pub fn compartment_id(mut self, compartment_id: impl Into<String>) -> Self {
404        self.compartment_id = Some(compartment_id.into());
405        self
406    }
407
408    // Internal helper for optional compartment_id
409    fn compartment_id_opt(mut self, compartment_id: Option<String>) -> Self {
410        self.compartment_id = compartment_id;
411        self
412    }
413
414    pub fn metadata_base_url(mut self, metadata_base_url: impl Into<String>) -> Self {
415        self.metadata_base_url = Some(metadata_base_url.into());
416        self
417    }
418
419    pub fn build(self) -> Result<Oci> {
420        let tenancy_id = self
421            .tenancy_id
422            .ok_or_else(|| Error::ConfigError("tenancy_id is not set".to_string()))?;
423        let region = self
424            .region
425            .ok_or_else(|| Error::ConfigError("region is not set".to_string()))?;
426        let realm_domain_component = self
427            .realm_domain_component
428            .unwrap_or_else(|| DEFAULT_REALM_DOMAIN_COMPONENT.to_owned());
429        let client = Client::builder().build()?;
430
431        let (signer, auth_provider) = match self.auth_mode {
432            AuthMode::ApiKey => {
433                let user_id = self
434                    .user_id
435                    .ok_or_else(|| Error::ConfigError("user_id is not set".to_owned()))?;
436                let fingerprint = self
437                    .fingerprint
438                    .ok_or_else(|| Error::ConfigError("fingerprint is not set".to_owned()))?;
439                let private_key = self
440                    .private_key
441                    .ok_or_else(|| Error::ConfigError("private_key is not set".to_owned()))?;
442                let signer = OciSigner::new(&user_id, &tenancy_id, &fingerprint, &private_key)?;
443                let provider =
444                    Arc::new(ApiKeyAuthProvider::new(signer.clone())) as DynOciAuthProvider;
445                (Some(signer), provider)
446            }
447            AuthMode::InstancePrincipal => {
448                let config = if let Some(metadata_base_url) = self.metadata_base_url {
449                    InstancePrincipalConfig::new(region.clone(), tenancy_id.clone())
450                        .realm_domain_component(realm_domain_component.clone())
451                        .metadata_base_url(metadata_base_url)
452                } else {
453                    InstancePrincipalConfig::new(region.clone(), tenancy_id.clone())
454                        .realm_domain_component(realm_domain_component.clone())
455                };
456                let provider = Arc::new(InstancePrincipalAuthProvider::new(client.clone(), config))
457                    as DynOciAuthProvider;
458                (None, provider)
459            }
460        };
461
462        Ok(Oci {
463            client,
464            region,
465            realm_domain_component,
466            tenancy_id,
467            compartment_id: self.compartment_id,
468            signer,
469            auth_mode: self.auth_mode,
470            auth_provider,
471        })
472    }
473}
474
475impl Default for AuthMode {
476    fn default() -> Self {
477        Self::ApiKey
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    use mockito::Server;
486    use serial_test::serial;
487
488    const TEST_VALID_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
489MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCvfVmTGipPCAsg
490fr8khhrPpQxmjUW62+pH/54EecyKTd8KTkg11wT40Pi5zB/UAl8DGTPs9MNz1PQX
491EGPh7YPccPTGJ4ZFfu87s2W9m3zp9UWUIy+n+Jr5FBpn8H7n7W/FPLTF7xRyzMSY
492BGWFKIyHkufglkKJlRkyVK8+0w6vFBg5Ni/0Eo0uTT31AWvv1b5nuCRstSCME2O7
493GbNUPo6vF1xEWNeFzp9Lp7JuMXu+tgLJiSkHKq7I2u25iQvklnqogDSLzxQigX/P
494+08jd52R9HI0rWiwLVJ1QE/erZJ+DnKjikb3jpHNRVZmG7/tDM/54yh85L0JfzZx
495yt+b3qS5AgMBAAECggEAGMAKERggnXLZ9uRJWwJa56w0eoY0Lm1ztmHTzHfNJDhl
496W5O81XMU7W6zlai3WHRZKBu22hWPN1fycQpLvAJ+lWmM7CGI62ZCoV3k3IAAdxKz
497lHf98ae7W6O9MamWjGlNWTj9mejlLme41mPQWZ5la32JnIA0tCjGG/YbnTWxHXnx
498B5skseaEMR3DT98uBZa67IFKDLJDIIaD4aQNILMNtEb2PFOChblA0mm2szR3AMhv
499Pl0VvrexHR+xdlteUBJ/G3Y3KuAB4MzTwl9rBarTmBaaZbl+iD1Kt3v+elNQdVCo
500JPSfGr9AbVdFDHB0FS46sWqOyk3Rx9lScigUWb0mvQKBgQDnfUQJ7Uhqm7FByXQs
501MWxLQIEHukWGG98btV2FjHO5N/IObrjXXUEl3qkTIW+oa+im48HRDKjlIZkTtN7l
502tbhqRlt9lW7PXtR+J+YjSXxAeourNaaMxbaVy3U/fhVVP5KrWfLzBbb0ZOF2A7gq
503g+rlHFVIVPOLj8lIPIlFjST9zwKBgQDCEiklTiFZZP6EjvgT7yMdJgvOkLFcJ4nF
504A1PL72S7nYPKbwQZt0eUohMA/PVkDyemNpafTYeGjKx+waS60Zcn1/S6CMMDkmJL
505DBAJVtCXwVmyaJTocS9kQwTeLqK+BBiHWL9nPTHmrTmEfrVwwB51eB9G+EJlv4fy
506J8f4yPie9wKBgQCt/u3hOEUyPIxjknSLsype9cEGefA/+TsdrJj7BLMHCRIb3wV4
507e1O4j0AubPdsdI+Owaqw4v8gGrzgnxbbOle/Kdsi7es4W2ME4CCPbXDDVlkc+1qQ
508fRvcQ+2BJ9gJF5u6yAVgvW7jC+Cbv/fxnO41/7HqiE/3GsCEV1wmtwyS6QKBgQCe
509h7VCuwr0+lIKuLsflYYKhoy4hWvMSqP44pnuCjUwKSCCGaOw2g3H9YkuknRl8xdB
510aHAr22os1/cEaGyHCzS9oGRSH1wmK8rNYSIsbtVgUdpSqamSIvtCnJh6YoAgVjov
511PajEzbFYrQJCIDtYyidXb/OkxqF+ejGz9xkcOhcVywKBgQCCmIJbRrHKB7YYPD68
512NJo0kGnesUmsBzrFxWsckCTYpVkqjDI4VPeOYVFpXtlPkVMIIy7PSjZHCu9ujcDC
513Oj3UlzzFzA70eAdkFrBlFxIembT4SjSoptN/8GP8wIe7xgnvj0gZJTH3W+z8AiBr
514Ae/wEOcaaJD3g0i9hhz8Blf4IA==
515-----END PRIVATE KEY-----"#;
516
517    const TENANT_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
518MIIDXzCCAkegAwIBAgIUONFqOCNE1N3Aps1ZQaPpY7SQzngwDQYJKoZIhvcNAQEL\n\
519BQAwPzEuMCwGA1UECgwlb3BjLXRlbmFudDpvY2lkMS50ZW5hbnR5Lm9jMS4uZXhh\n\
520bXBsZTENMAsGA1UEAwwEdGVzdDAeFw0yNjA1MTEwNjQ1NTFaFw0yNjA1MTIwNjQ1\n\
521NTFaMD8xLjAsBgNVBAoMJW9wYy10ZW5hbnQ6b2NpZDEudGVuYW5jeS5vYzEuLmV4\n\
522YW1wbGUxDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\n\
523AoIBAQDMblfnza9gqREWumv1mTJbR939nQIYZUynTxusVBXciNRjKaqB0jFSUFg9\n\
524E2pwtr7G/zr6rpIum9yaRT3O/hhIACP7CJvOoIPTV8qDmNcRnlT78nWBN8jnma1A\n\
525T9AZhtR14BJVe03eSSHBTnIDNNDQZu1+p6hUiGPVG1xe/F3/HOwbUrxzsChDnliZ\n\
526C46FL0JMIu/uH/Q/iSg0wYsJQKzE+iIvLo5edTeaTvdaTth8XLmltWM2DEwC/fyU\n\
527D2lxoOmvBhCVl1OCvT3Db0hMXRVV79BAXNS+qUyKbWnAgkiAMDGmEtYzizAoqCl4\n\
528GpDeqNfSI/xo8Zt1RqU1PgleQslDAgMBAAGjUzBRMB0GA1UdDgQWBBRnTn//hXKL\n\
529fWGEt7RY27CGihg+DjAfBgNVHSMEGDAWgBRnTn//hXKLfWGEt7RY27CGihg+DjAP\n\
530BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAwRR1OsfwCP1UF4PWK\n\
531jQLcBHrwEL7q9/HG47G6IsD4YN365ZPKzv7cOVzL7sPXVs18f3XDZwVNhwMiP2lo\n\
532ShLlHDIog2ZMD0kppoZlwf1EdbVVOr30qtHaRpd1/YHY1omuUCdis51iJzO/wMwL\n\
533m3yCFx7OCb46vCHwWc+CwiF9I9HKFMJyVpmhsEw91EPH3JaHWW1wn/RSIXuWpX0Q\n\
534t+CmwNhI9TC99JL2cfr5lFUjA8nQ5Xx68L9gyfQZ2aicx5XD+s+nt0mgc06oOWv3\n\
535ubYEGH/Vy8oK3rEoKdcNVdZUTgA0Fs2g+ItlrBFsJl5A1/TP3f0fbV6j9eY2SpdB\n\
536Eo34\n\
537-----END CERTIFICATE-----\n";
538
539    struct EnvGuard {
540        saved: Vec<(&'static str, Option<String>)>,
541    }
542
543    impl EnvGuard {
544        fn new(keys: &[&'static str]) -> Self {
545            Self {
546                saved: keys.iter().map(|key| (*key, env::var(key).ok())).collect(),
547            }
548        }
549
550        fn set(&self, key: &'static str, value: Option<&str>) {
551            unsafe {
552                match value {
553                    Some(value) => env::set_var(key, value),
554                    None => env::remove_var(key),
555                }
556            }
557        }
558    }
559
560    impl Drop for EnvGuard {
561        fn drop(&mut self) {
562            for (key, value) in &self.saved {
563                unsafe {
564                    match value {
565                        Some(value) => env::set_var(key, value),
566                        None => env::remove_var(key),
567                    }
568                }
569            }
570        }
571    }
572
573    #[test]
574    #[serial]
575    fn resolve_auth_mode_prefers_explicit_api_key_over_oci_autodetect() {
576        let mut server = Server::new();
577        let _region_info = server
578            .mock("GET", "/opc/v2/instance/regionInfo")
579            .match_header("authorization", "Bearer Oracle")
580            .with_status(200)
581            .with_body(
582                r#"{"realmKey":"oc1","realmDomainComponent":"oraclecloud.com","regionKey":"PHX","regionIdentifier":"us-phoenix-1"}"#,
583            )
584            .create();
585
586        let guard = EnvGuard::new(&[
587            "OCI_AUTH_MODE",
588            "OCI_REGION",
589            "OCI_TENANCY_ID",
590            "OCI_METADATA_BASE_URL",
591            "OCI_COMPARTMENT_ID",
592        ]);
593        guard.set("OCI_AUTH_MODE", Some("api_key"));
594        guard.set("OCI_REGION", None);
595        guard.set("OCI_TENANCY_ID", None);
596        guard.set(
597            "OCI_METADATA_BASE_URL",
598            Some(&format!("{}/opc/v2", server.url())),
599        );
600        guard.set("OCI_COMPARTMENT_ID", None);
601
602        assert_eq!(Oci::resolve_auth_mode_from_env().unwrap(), AuthMode::ApiKey);
603    }
604
605    #[test]
606    #[serial]
607    fn resolve_auth_mode_autodetects_instance_principal_when_metadata_is_reachable() {
608        let mut server = Server::new();
609        let _region_info = server
610            .mock("GET", "/opc/v2/instance/regionInfo")
611            .match_header("authorization", "Bearer Oracle")
612            .with_status(200)
613            .with_body(
614                r#"{"realmKey":"oc1","realmDomainComponent":"oraclecloud.com","regionKey":"PHX","regionIdentifier":"us-phoenix-1"}"#,
615            )
616            .create();
617
618        let guard = EnvGuard::new(&[
619            "OCI_AUTH_MODE",
620            "OCI_REGION",
621            "OCI_TENANCY_ID",
622            "OCI_METADATA_BASE_URL",
623            "OCI_COMPARTMENT_ID",
624        ]);
625        guard.set("OCI_AUTH_MODE", None);
626        guard.set("OCI_REGION", None);
627        guard.set("OCI_TENANCY_ID", None);
628        guard.set(
629            "OCI_METADATA_BASE_URL",
630            Some(&format!("{}/opc/v2", server.url())),
631        );
632        guard.set("OCI_COMPARTMENT_ID", None);
633
634        assert_eq!(
635            Oci::resolve_auth_mode_from_env().unwrap(),
636            AuthMode::InstancePrincipal
637        );
638    }
639
640    #[test]
641    #[serial]
642    fn resolve_auth_mode_falls_back_to_api_key_when_metadata_is_unavailable() {
643        let guard = EnvGuard::new(&[
644            "OCI_AUTH_MODE",
645            "OCI_REGION",
646            "OCI_TENANCY_ID",
647            "OCI_METADATA_BASE_URL",
648        ]);
649        guard.set("OCI_AUTH_MODE", None);
650        guard.set("OCI_REGION", None);
651        guard.set("OCI_TENANCY_ID", None);
652        guard.set("OCI_METADATA_BASE_URL", Some("http://127.0.0.1:9/opc/v2"));
653
654        assert_eq!(Oci::resolve_auth_mode_from_env().unwrap(), AuthMode::ApiKey);
655    }
656
657    #[test]
658    #[serial]
659    fn resolve_auth_mode_treats_empty_override_as_unset() {
660        let guard = EnvGuard::new(&["OCI_AUTH_MODE", "OCI_METADATA_BASE_URL"]);
661        guard.set("OCI_AUTH_MODE", Some("  "));
662        guard.set("OCI_METADATA_BASE_URL", Some("http://127.0.0.1:9/opc/v2"));
663
664        assert_eq!(Oci::resolve_auth_mode_from_env().unwrap(), AuthMode::ApiKey);
665    }
666
667    #[test]
668    #[serial]
669    fn resolve_auth_mode_rejects_invalid_override() {
670        let guard = EnvGuard::new(&["OCI_AUTH_MODE"]);
671        guard.set("OCI_AUTH_MODE", Some("foo"));
672
673        let error = Oci::resolve_auth_mode_from_env().unwrap_err();
674        assert!(matches!(error, Error::EnvError(_)));
675        assert!(
676            error
677                .to_string()
678                .contains("OCI_AUTH_MODE must be 'api_key' or 'instance_principal'")
679        );
680    }
681
682    #[test]
683    #[serial]
684    fn from_env_autodetects_instance_principal_when_bootstrap_envs_are_missing() {
685        let mut server = Server::new();
686        let _region_info = server
687            .mock("GET", "/opc/v2/instance/regionInfo")
688            .match_header("authorization", "Bearer Oracle")
689            .with_status(200)
690            .with_body(
691                r#"{"realmKey":"oc1","realmDomainComponent":"oraclecloud.com","regionKey":"PHX","regionIdentifier":"us-phoenix-1"}"#,
692            )
693            .create();
694        let _leaf_cert = server
695            .mock("GET", "/opc/v2/identity/cert.pem")
696            .match_header("authorization", "Bearer Oracle")
697            .with_status(200)
698            .with_body(TENANT_CERT_PEM)
699            .create();
700
701        let guard = EnvGuard::new(&[
702            "OCI_AUTH_MODE",
703            "OCI_REGION",
704            "OCI_TENANCY_ID",
705            "OCI_METADATA_BASE_URL",
706            "OCI_COMPARTMENT_ID",
707        ]);
708        guard.set("OCI_AUTH_MODE", None);
709        guard.set("OCI_REGION", None);
710        guard.set("OCI_TENANCY_ID", None);
711        guard.set(
712            "OCI_METADATA_BASE_URL",
713            Some(&format!("{}/opc/v2", server.url())),
714        );
715        guard.set("OCI_COMPARTMENT_ID", None);
716
717        let oci = Oci::from_env().unwrap();
718
719        assert_eq!(oci.region(), "us-phoenix-1");
720        assert_eq!(oci.realm_domain(), "oraclecloud.com");
721        assert_eq!(oci.tenancy_id(), "ocid1.tenancy.oc1..example");
722        assert_eq!(oci.auth_mode(), AuthMode::InstancePrincipal);
723    }
724
725    #[test]
726    #[serial]
727    fn from_env_uses_explicit_api_key_mode_when_requested() {
728        let guard = EnvGuard::new(&[
729            "OCI_AUTH_MODE",
730            "OCI_USER_ID",
731            "OCI_TENANCY_ID",
732            "OCI_REGION",
733            "OCI_FINGERPRINT",
734            "OCI_PRIVATE_KEY",
735            "OCI_CONFIG",
736        ]);
737        guard.set("OCI_AUTH_MODE", Some("api_key"));
738        guard.set("OCI_USER_ID", Some("ocid1.user.oc1..example"));
739        guard.set("OCI_TENANCY_ID", Some("ocid1.tenancy.oc1..example"));
740        guard.set("OCI_REGION", Some("ap-chuncheon-1"));
741        guard.set(
742            "OCI_FINGERPRINT",
743            Some("11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00"),
744        );
745        guard.set("OCI_PRIVATE_KEY", Some(TEST_VALID_PEM));
746        guard.set("OCI_CONFIG", None);
747
748        let oci = Oci::from_env().unwrap();
749
750        assert_eq!(oci.auth_mode(), AuthMode::ApiKey);
751        assert_eq!(oci.region(), "ap-chuncheon-1");
752    }
753}