Skip to main content

purple_ssh/providers/
oracle.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::SystemTime;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD;
7use rsa::pkcs1::DecodeRsaPrivateKey;
8use rsa::pkcs8::DecodePrivateKey;
9use rsa::signature::{SignatureEncoding, Signer};
10use serde::Deserialize;
11
12use super::{Provider, ProviderError, ProviderHost};
13
14/// Oracle Cloud Infrastructure provider configuration.
15pub struct Oracle {
16    pub regions: Vec<String>,
17    pub compartment: String,
18}
19
20/// Parsed OCI API credentials.
21#[derive(Debug)]
22struct OciCredentials {
23    tenancy: String,
24    user: String,
25    fingerprint: String,
26    key_pem: String,
27    region: String,
28}
29
30/// Parse an OCI config file and return credentials.
31///
32/// Only the `[DEFAULT]` profile is read (case-sensitive). The `key_pem`
33/// field comes from the already-read key file content passed as
34/// `key_content`.
35fn parse_oci_config(content: &str, key_content: &str) -> Result<OciCredentials, ProviderError> {
36    let mut in_default = false;
37    let mut tenancy: Option<String> = None;
38    let mut user: Option<String> = None;
39    let mut fingerprint: Option<String> = None;
40    let mut region: Option<String> = None;
41
42    for raw_line in content.lines() {
43        // Strip CRLF by stripping trailing \r after lines() removes \n
44        let line = raw_line.trim_end_matches('\r');
45        let trimmed = line.trim();
46
47        if trimmed.starts_with('[') && trimmed.ends_with(']') {
48            let profile = &trimmed[1..trimmed.len() - 1];
49            in_default = profile == "DEFAULT";
50            continue;
51        }
52
53        if !in_default {
54            continue;
55        }
56
57        if trimmed.starts_with('#') || trimmed.is_empty() {
58            continue;
59        }
60
61        if let Some(eq) = trimmed.find('=') {
62            let key = trimmed[..eq].trim();
63            let val = trimmed[eq + 1..].trim().to_string();
64            match key {
65                "tenancy" => tenancy = Some(val),
66                "user" => user = Some(val),
67                "fingerprint" => fingerprint = Some(val),
68                "region" => region = Some(val),
69                _ => {}
70            }
71        }
72    }
73
74    let tenancy = tenancy
75        .ok_or_else(|| ProviderError::Http("OCI config missing 'tenancy' in [DEFAULT]".into()))?;
76    let user =
77        user.ok_or_else(|| ProviderError::Http("OCI config missing 'user' in [DEFAULT]".into()))?;
78    let fingerprint = fingerprint.ok_or_else(|| {
79        ProviderError::Http("OCI config missing 'fingerprint' in [DEFAULT]".into())
80    })?;
81    let region = region.unwrap_or_default();
82
83    Ok(OciCredentials {
84        tenancy,
85        user,
86        fingerprint,
87        key_pem: key_content.to_string(),
88        region,
89    })
90}
91
92/// Extract the `key_file` path from the `[DEFAULT]` profile of an OCI
93/// config file.
94fn extract_key_file(config_content: &str) -> Result<String, ProviderError> {
95    let mut in_default = false;
96
97    for raw_line in config_content.lines() {
98        let line = raw_line.trim_end_matches('\r');
99        let trimmed = line.trim();
100
101        if trimmed.starts_with('[') && trimmed.ends_with(']') {
102            let profile = &trimmed[1..trimmed.len() - 1];
103            in_default = profile == "DEFAULT";
104            continue;
105        }
106
107        if !in_default || trimmed.starts_with('#') || trimmed.is_empty() {
108            continue;
109        }
110
111        if let Some(eq) = trimmed.find('=') {
112            let key = trimmed[..eq].trim();
113            if key == "key_file" {
114                return Ok(trimmed[eq + 1..].trim().to_string());
115            }
116        }
117    }
118
119    Err(ProviderError::Http(
120        "OCI config missing 'key_file' in [DEFAULT]".into(),
121    ))
122}
123
124/// Validate that an OCID string has a compartment or tenancy prefix.
125fn validate_compartment(ocid: &str) -> Result<(), ProviderError> {
126    if ocid.starts_with("ocid1.compartment.oc1..") || ocid.starts_with("ocid1.tenancy.oc1..") {
127        Ok(())
128    } else {
129        Err(ProviderError::Http(format!(
130            "Invalid compartment OCID: '{}'. Must start with 'ocid1.compartment.oc1..' or 'ocid1.tenancy.oc1..'",
131            ocid
132        )))
133    }
134}
135
136// ---------------------------------------------------------------------------
137// RFC 7231 date formatting
138// ---------------------------------------------------------------------------
139
140const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
141const MONTHS: [&str; 12] = [
142    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
143];
144
145/// Format a Unix timestamp as an RFC 7231 date string.
146///
147/// Example: `Thu, 26 Mar 2026 12:00:00 GMT`
148fn format_rfc7231(epoch_secs: u64) -> String {
149    let d = super::epoch_to_date(epoch_secs);
150    // Day of week: Jan 1 1970 was a Thursday (index 0 in WEEKDAYS)
151    let weekday = WEEKDAYS[(d.epoch_days % 7) as usize];
152    format!(
153        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
154        weekday,
155        d.day,
156        MONTHS[(d.month - 1) as usize],
157        d.year,
158        d.hours,
159        d.minutes,
160        d.seconds,
161    )
162}
163
164// ---------------------------------------------------------------------------
165// RSA private key parsing
166// ---------------------------------------------------------------------------
167
168/// Parse a PEM-encoded RSA private key (PKCS#1 or PKCS#8).
169fn parse_private_key(pem: &str) -> Result<rsa::RsaPrivateKey, ProviderError> {
170    if pem.contains("ENCRYPTED") {
171        return Err(ProviderError::Http(
172            "OCI private key is encrypted. Please provide an unencrypted key.".into(),
173        ));
174    }
175
176    // Try PKCS#1 first, then PKCS#8
177    if let Ok(key) = rsa::RsaPrivateKey::from_pkcs1_pem(pem) {
178        return Ok(key);
179    }
180
181    rsa::RsaPrivateKey::from_pkcs8_pem(pem)
182        .map_err(|e| ProviderError::Http(format!("Failed to parse OCI private key: {}", e)))
183}
184
185// ---------------------------------------------------------------------------
186// HTTP request signing
187// ---------------------------------------------------------------------------
188
189/// Build the OCI `Authorization` header value for a GET request.
190///
191/// Signs `date`, `(request-target)` and `host` headers using RSA-SHA256.
192/// The caller must parse the RSA private key once and pass it in to avoid
193/// re-parsing on every request.
194fn sign_request(
195    creds: &OciCredentials,
196    rsa_key: &rsa::RsaPrivateKey,
197    date: &str,
198    host: &str,
199    path_and_query: &str,
200) -> Result<String, ProviderError> {
201    let signing_string = format!(
202        "date: {}\n(request-target): get {}\nhost: {}",
203        date, path_and_query, host
204    );
205
206    let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(rsa_key.clone());
207    let signature = signing_key.sign(signing_string.as_bytes());
208    let sig_b64 = STANDARD.encode(signature.to_bytes());
209
210    let key_id = format!("{}/{}/{}", creds.tenancy, creds.user, creds.fingerprint);
211    Ok(format!(
212        "Signature version=\"1\",keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"date (request-target) host\",signature=\"{}\"",
213        key_id, sig_b64
214    ))
215}
216
217// ---------------------------------------------------------------------------
218// JSON response models
219// ---------------------------------------------------------------------------
220
221#[derive(Deserialize)]
222struct OciCompartment {
223    id: String,
224    #[serde(rename = "lifecycleState")]
225    lifecycle_state: String,
226}
227
228#[derive(Deserialize)]
229struct OciInstance {
230    id: String,
231    #[serde(rename = "displayName")]
232    display_name: String,
233    #[serde(rename = "lifecycleState")]
234    lifecycle_state: String,
235    shape: String,
236    #[serde(rename = "imageId")]
237    image_id: Option<String>,
238    #[serde(rename = "freeformTags")]
239    freeform_tags: Option<std::collections::HashMap<String, String>>,
240}
241
242#[derive(Deserialize)]
243struct OciVnicAttachment {
244    #[serde(rename = "instanceId")]
245    instance_id: String,
246    #[serde(rename = "vnicId")]
247    vnic_id: Option<String>,
248    #[serde(rename = "lifecycleState")]
249    lifecycle_state: String,
250    #[serde(rename = "isPrimary")]
251    is_primary: Option<bool>,
252}
253
254#[derive(Deserialize)]
255struct OciVnic {
256    #[serde(rename = "publicIp")]
257    public_ip: Option<String>,
258    #[serde(rename = "privateIp")]
259    private_ip: Option<String>,
260}
261
262#[derive(Deserialize)]
263struct OciImage {
264    #[serde(rename = "displayName")]
265    display_name: Option<String>,
266}
267
268// ureq 3.x does not expose the response body on StatusCode errors, so we
269// cannot parse OCI error JSON from failed responses. Other providers in this
270// codebase handle errors the same way (status code only). Kept for future use
271// if ureq adds body-on-error support.
272#[derive(Deserialize)]
273#[allow(dead_code)]
274struct OciErrorBody {
275    code: Option<String>,
276    message: Option<String>,
277}
278
279// ---------------------------------------------------------------------------
280// IP selection, VNIC mapping and helpers
281// ---------------------------------------------------------------------------
282
283fn select_ip(vnic: &OciVnic) -> String {
284    if let Some(ip) = &vnic.public_ip {
285        if !ip.is_empty() {
286            return ip.clone();
287        }
288    }
289    if let Some(ip) = &vnic.private_ip {
290        if !ip.is_empty() {
291            return ip.clone();
292        }
293    }
294    String::new()
295}
296
297fn select_vnic_for_instance(
298    attachments: &[OciVnicAttachment],
299    instance_id: &str,
300) -> Option<String> {
301    let matching: Vec<_> = attachments
302        .iter()
303        .filter(|a| a.instance_id == instance_id && a.lifecycle_state == "ATTACHED")
304        .collect();
305    if let Some(primary) = matching.iter().find(|a| a.is_primary == Some(true)) {
306        return primary.vnic_id.clone();
307    }
308    matching.first().and_then(|a| a.vnic_id.clone())
309}
310
311fn extract_tags(freeform_tags: &Option<std::collections::HashMap<String, String>>) -> Vec<String> {
312    match freeform_tags {
313        Some(tags) => {
314            let mut result: Vec<String> = tags
315                .iter()
316                .map(|(k, v)| {
317                    if v.is_empty() {
318                        k.clone()
319                    } else {
320                        format!("{}:{}", k, v)
321                    }
322                })
323                .collect();
324            result.sort();
325            result
326        }
327        None => Vec::new(),
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Region constants
333// ---------------------------------------------------------------------------
334
335pub const OCI_REGIONS: &[(&str, &str)] = &[
336    // Americas (0..12)
337    ("us-ashburn-1", "Ashburn"),
338    ("us-phoenix-1", "Phoenix"),
339    ("us-sanjose-1", "San Jose"),
340    ("us-chicago-1", "Chicago"),
341    ("ca-toronto-1", "Toronto"),
342    ("ca-montreal-1", "Montreal"),
343    ("br-saopaulo-1", "Sao Paulo"),
344    ("br-vinhedo-1", "Vinhedo"),
345    ("mx-queretaro-1", "Queretaro"),
346    ("mx-monterrey-1", "Monterrey"),
347    ("cl-santiago-1", "Santiago"),
348    ("co-bogota-1", "Bogota"),
349    // EMEA (12..29)
350    ("eu-amsterdam-1", "Amsterdam"),
351    ("eu-frankfurt-1", "Frankfurt"),
352    ("eu-zurich-1", "Zurich"),
353    ("eu-stockholm-1", "Stockholm"),
354    ("eu-marseille-1", "Marseille"),
355    ("eu-milan-1", "Milan"),
356    ("eu-paris-1", "Paris"),
357    ("eu-madrid-1", "Madrid"),
358    ("eu-jovanovac-1", "Jovanovac"),
359    ("uk-london-1", "London"),
360    ("uk-cardiff-1", "Cardiff"),
361    ("me-jeddah-1", "Jeddah"),
362    ("me-abudhabi-1", "Abu Dhabi"),
363    ("me-dubai-1", "Dubai"),
364    ("me-riyadh-1", "Riyadh"),
365    ("af-johannesburg-1", "Johannesburg"),
366    ("il-jerusalem-1", "Jerusalem"),
367    // Asia Pacific (29..38)
368    ("ap-tokyo-1", "Tokyo"),
369    ("ap-osaka-1", "Osaka"),
370    ("ap-seoul-1", "Seoul"),
371    ("ap-chuncheon-1", "Chuncheon"),
372    ("ap-singapore-1", "Singapore"),
373    ("ap-sydney-1", "Sydney"),
374    ("ap-melbourne-1", "Melbourne"),
375    ("ap-mumbai-1", "Mumbai"),
376    ("ap-hyderabad-1", "Hyderabad"),
377];
378
379pub const OCI_REGION_GROUPS: &[(&str, usize, usize)] = &[
380    ("Americas", 0, 12),
381    ("EMEA", 12, 29),
382    ("Asia Pacific", 29, 38),
383];
384
385// ---------------------------------------------------------------------------
386// Provider trait implementation
387// ---------------------------------------------------------------------------
388
389impl Provider for Oracle {
390    fn name(&self) -> &str {
391        "oracle"
392    }
393
394    fn short_label(&self) -> &str {
395        "oci"
396    }
397
398    fn fetch_hosts_cancellable(
399        &self,
400        token: &str,
401        cancel: &AtomicBool,
402    ) -> Result<Vec<ProviderHost>, ProviderError> {
403        self.fetch_hosts_with_progress(token, cancel, &|_| {})
404    }
405
406    fn fetch_hosts_with_progress(
407        &self,
408        token: &str,
409        cancel: &AtomicBool,
410        progress: &dyn Fn(&str),
411    ) -> Result<Vec<ProviderHost>, ProviderError> {
412        if self.compartment.is_empty() {
413            return Err(ProviderError::Http(
414                "No compartment configured. Run: purple provider add oracle --token ~/.oci/config --compartment <OCID>".to_string(),
415            ));
416        }
417        validate_compartment(&self.compartment)?;
418
419        let config_content = std::fs::read_to_string(token).map_err(|e| {
420            ProviderError::Http(format!("Cannot read OCI config file '{}': {}", token, e))
421        })?;
422        let key_file = extract_key_file(&config_content)?;
423        let expanded = if key_file.starts_with("~/") {
424            if let Some(home) = dirs::home_dir() {
425                format!("{}{}", home.display(), &key_file[1..])
426            } else {
427                key_file.clone()
428            }
429        } else {
430            key_file.clone()
431        };
432        let key_content = std::fs::read_to_string(&expanded).map_err(|e| {
433            ProviderError::Http(format!("Cannot read OCI private key '{}': {}", expanded, e))
434        })?;
435        let creds = parse_oci_config(&config_content, &key_content)?;
436        let rsa_key = parse_private_key(&creds.key_pem)?;
437
438        let regions: Vec<String> = if self.regions.is_empty() {
439            if creds.region.is_empty() {
440                return Err(ProviderError::Http(
441                    "No regions configured and OCI config has no default region".to_string(),
442                ));
443            }
444            vec![creds.region.clone()]
445        } else {
446            self.regions.clone()
447        };
448
449        let mut all_hosts = Vec::new();
450        let mut region_failures = 0usize;
451        let total_regions = regions.len();
452        for region in &regions {
453            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
454                return Err(ProviderError::Cancelled);
455            }
456            progress(&format!("Syncing {} ...", region));
457            match self.fetch_region(&creds, &rsa_key, region, cancel, progress) {
458                Ok(mut hosts) => all_hosts.append(&mut hosts),
459                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
460                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
461                Err(ProviderError::Cancelled) => return Err(ProviderError::Cancelled),
462                Err(ProviderError::PartialResult {
463                    hosts: mut partial, ..
464                }) => {
465                    all_hosts.append(&mut partial);
466                    region_failures += 1;
467                }
468                Err(_) => {
469                    region_failures += 1;
470                }
471            }
472        }
473        if region_failures > 0 {
474            if all_hosts.is_empty() {
475                return Err(ProviderError::Http(format!(
476                    "Failed to sync all {} region(s)",
477                    total_regions
478                )));
479            }
480            return Err(ProviderError::PartialResult {
481                hosts: all_hosts,
482                failures: region_failures,
483                total: total_regions,
484            });
485        }
486        Ok(all_hosts)
487    }
488}
489
490impl Oracle {
491    /// Perform a signed GET request against the OCI API.
492    fn signed_get(
493        &self,
494        creds: &OciCredentials,
495        rsa_key: &rsa::RsaPrivateKey,
496        agent: &ureq::Agent,
497        host: &str,
498        url: &str,
499    ) -> Result<ureq::http::Response<ureq::Body>, ProviderError> {
500        let now = SystemTime::now()
501            .duration_since(SystemTime::UNIX_EPOCH)
502            .unwrap_or_default()
503            .as_secs();
504        let date = format_rfc7231(now);
505
506        // Extract path+query from URL (everything after the host part)
507        let path_and_query = if let Some(pos) = url.find(host) {
508            &url[pos + host.len()..]
509        } else {
510            // Fallback: strip scheme + host
511            url.splitn(4, '/').nth(3).map_or("/", |p| {
512                // We need the leading slash
513                &url[url.len() - p.len() - 1..]
514            })
515        };
516
517        let auth = sign_request(creds, rsa_key, &date, host, path_and_query)?;
518
519        agent
520            .get(url)
521            .header("date", &date)
522            .header("Authorization", &auth)
523            .call()
524            .map_err(|e| match e {
525                ureq::Error::StatusCode(401 | 403) => ProviderError::AuthFailed,
526                ureq::Error::StatusCode(429) => ProviderError::RateLimited,
527                ureq::Error::StatusCode(code) => ProviderError::Http(format!("HTTP {}", code)),
528                other => super::map_ureq_error(other),
529            })
530    }
531
532    /// List active sub-compartments (Identity API supports compartmentIdInSubtree).
533    fn list_compartments(
534        &self,
535        creds: &OciCredentials,
536        rsa_key: &rsa::RsaPrivateKey,
537        agent: &ureq::Agent,
538        region: &str,
539        cancel: &AtomicBool,
540    ) -> Result<Vec<String>, ProviderError> {
541        let host = format!("identity.{}.oraclecloud.com", region);
542        let compartment_encoded = urlencoding_encode(&self.compartment);
543
544        let mut compartment_ids = vec![self.compartment.clone()];
545        let mut next_page: Option<String> = None;
546        for _ in 0..500 {
547            if cancel.load(Ordering::Relaxed) {
548                return Err(ProviderError::Cancelled);
549            }
550
551            let url = match &next_page {
552                Some(page) => format!(
553                    "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100&page={}",
554                    host,
555                    compartment_encoded,
556                    urlencoding_encode(page)
557                ),
558                None => format!(
559                    "https://{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
560                    host, compartment_encoded
561                ),
562            };
563
564            let mut resp = self.signed_get(creds, rsa_key, agent, &host, &url)?;
565
566            let opc_next = resp
567                .headers()
568                .get("opc-next-page")
569                .and_then(|v| v.to_str().ok())
570                .filter(|s| !s.is_empty())
571                .map(String::from);
572
573            let items: Vec<OciCompartment> = resp
574                .body_mut()
575                .read_json()
576                .map_err(|e| ProviderError::Parse(e.to_string()))?;
577
578            compartment_ids.extend(
579                items
580                    .into_iter()
581                    .filter(|c| c.lifecycle_state == "ACTIVE")
582                    .map(|c| c.id),
583            );
584
585            match opc_next {
586                Some(p) => next_page = Some(p),
587                None => break,
588            }
589        }
590        Ok(compartment_ids)
591    }
592
593    fn fetch_region(
594        &self,
595        creds: &OciCredentials,
596        rsa_key: &rsa::RsaPrivateKey,
597        region: &str,
598        cancel: &AtomicBool,
599        progress: &dyn Fn(&str),
600    ) -> Result<Vec<ProviderHost>, ProviderError> {
601        let agent = super::http_agent();
602        let host = format!("iaas.{}.oraclecloud.com", region);
603
604        // Step 0: Discover all compartments (root + sub-compartments)
605        progress("Listing compartments...");
606        let compartment_ids = self.list_compartments(creds, rsa_key, &agent, region, cancel)?;
607        let total_compartments = compartment_ids.len();
608
609        // Step 1: List instances across all compartments (paginated per compartment)
610        let mut instances: Vec<OciInstance> = Vec::new();
611        for (ci, comp_id) in compartment_ids.iter().enumerate() {
612            if cancel.load(Ordering::Relaxed) {
613                return Err(ProviderError::Cancelled);
614            }
615            if total_compartments > 1 {
616                progress(&format!(
617                    "Listing instances ({}/{} compartments)...",
618                    ci + 1,
619                    total_compartments
620                ));
621            } else {
622                progress("Listing instances...");
623            }
624            let compartment_encoded = urlencoding_encode(comp_id);
625            let mut next_page: Option<String> = None;
626            for _ in 0..500 {
627                if cancel.load(Ordering::Relaxed) {
628                    return Err(ProviderError::Cancelled);
629                }
630
631                let url = match &next_page {
632                    Some(page) => format!(
633                        "https://{}/20160918/instances?compartmentId={}&limit=100&page={}",
634                        host,
635                        compartment_encoded,
636                        urlencoding_encode(page)
637                    ),
638                    None => format!(
639                        "https://{}/20160918/instances?compartmentId={}&limit=100",
640                        host, compartment_encoded
641                    ),
642                };
643
644                let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
645
646                let opc_next = resp
647                    .headers()
648                    .get("opc-next-page")
649                    .and_then(|v| v.to_str().ok())
650                    .filter(|s| !s.is_empty())
651                    .map(String::from);
652
653                let page_items: Vec<OciInstance> = resp
654                    .body_mut()
655                    .read_json()
656                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
657
658                instances.extend(
659                    page_items
660                        .into_iter()
661                        .filter(|i| i.lifecycle_state != "TERMINATED"),
662                );
663
664                match opc_next {
665                    Some(p) => next_page = Some(p),
666                    None => break,
667                }
668            }
669        }
670
671        // Step 2: List VNIC attachments across all compartments (paginated per compartment)
672        progress("Listing VNIC attachments...");
673        let mut attachments: Vec<OciVnicAttachment> = Vec::new();
674        for comp_id in &compartment_ids {
675            if cancel.load(Ordering::Relaxed) {
676                return Err(ProviderError::Cancelled);
677            }
678            let compartment_encoded = urlencoding_encode(comp_id);
679            let mut next_page: Option<String> = None;
680            for _ in 0..500 {
681                if cancel.load(Ordering::Relaxed) {
682                    return Err(ProviderError::Cancelled);
683                }
684
685                let url = match &next_page {
686                    Some(page) => format!(
687                        "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100&page={}",
688                        host,
689                        compartment_encoded,
690                        urlencoding_encode(page)
691                    ),
692                    None => format!(
693                        "https://{}/20160918/vnicAttachments?compartmentId={}&limit=100",
694                        host, compartment_encoded
695                    ),
696                };
697
698                let mut resp = self.signed_get(creds, rsa_key, &agent, &host, &url)?;
699
700                let opc_next = resp
701                    .headers()
702                    .get("opc-next-page")
703                    .and_then(|v| v.to_str().ok())
704                    .filter(|s| !s.is_empty())
705                    .map(String::from);
706
707                let page_items: Vec<OciVnicAttachment> = resp
708                    .body_mut()
709                    .read_json()
710                    .map_err(|e| ProviderError::Parse(e.to_string()))?;
711
712                attachments.extend(page_items);
713
714                match opc_next {
715                    Some(p) => next_page = Some(p),
716                    None => break,
717                }
718            }
719        }
720
721        // Step 3: Resolve images (N+1 per unique imageId)
722        let unique_image_ids: Vec<String> = {
723            let mut ids: Vec<String> = instances
724                .iter()
725                .filter_map(|i| i.image_id.clone())
726                .collect();
727            ids.sort_unstable();
728            ids.dedup();
729            ids
730        };
731        let total_images = unique_image_ids.len();
732        let mut image_names: HashMap<String, String> = HashMap::new();
733        for (n, image_id) in unique_image_ids.iter().enumerate() {
734            if cancel.load(Ordering::Relaxed) {
735                return Err(ProviderError::Cancelled);
736            }
737            progress(&format!("Resolving images ({}/{})...", n + 1, total_images));
738
739            let url = format!("https://{}/20160918/images/{}", host, image_id);
740            match self.signed_get(creds, rsa_key, &agent, &host, &url) {
741                Ok(mut resp) => {
742                    if let Ok(img) = resp.body_mut().read_json::<OciImage>() {
743                        if let Some(name) = img.display_name {
744                            image_names.insert(image_id.clone(), name);
745                        }
746                    }
747                }
748                Err(ProviderError::AuthFailed) => return Err(ProviderError::AuthFailed),
749                Err(ProviderError::RateLimited) => return Err(ProviderError::RateLimited),
750                Err(_) => {} // Non-fatal: skip silently
751            }
752        }
753
754        // Step 4: Get VNIC + build hosts (N+1 per VNIC for RUNNING instances)
755        let total_instances = instances.len();
756        let mut hosts: Vec<ProviderHost> = Vec::new();
757        let mut fetch_failures = 0usize;
758        for (n, instance) in instances.iter().enumerate() {
759            if cancel.load(Ordering::Relaxed) {
760                return Err(ProviderError::Cancelled);
761            }
762            progress(&format!("Fetching IPs ({}/{})...", n + 1, total_instances));
763
764            let ip = if instance.lifecycle_state == "RUNNING" {
765                match select_vnic_for_instance(&attachments, &instance.id) {
766                    Some(vnic_id) => {
767                        let url = format!("https://{}/20160918/vnics/{}", host, vnic_id);
768                        match self.signed_get(creds, rsa_key, &agent, &host, &url) {
769                            Ok(mut resp) => match resp.body_mut().read_json::<OciVnic>() {
770                                Ok(vnic) => {
771                                    let raw = select_ip(&vnic);
772                                    super::strip_cidr(&raw).to_string()
773                                }
774                                Err(_) => {
775                                    fetch_failures += 1;
776                                    String::new()
777                                }
778                            },
779                            Err(ProviderError::AuthFailed) => {
780                                return Err(ProviderError::AuthFailed);
781                            }
782                            Err(ProviderError::RateLimited) => {
783                                return Err(ProviderError::RateLimited);
784                            }
785                            Err(ProviderError::Http(ref msg)) if msg == "HTTP 404" => {
786                                // 404: race condition, silent skip
787                                String::new()
788                            }
789                            Err(_) => {
790                                fetch_failures += 1;
791                                String::new()
792                            }
793                        }
794                    }
795                    None => String::new(),
796                }
797            } else {
798                String::new()
799            };
800
801            let os_name = instance
802                .image_id
803                .as_ref()
804                .and_then(|id| image_names.get(id))
805                .cloned()
806                .unwrap_or_default();
807
808            let mut metadata = Vec::new();
809            metadata.push(("region".to_string(), region.to_string()));
810            metadata.push(("shape".to_string(), instance.shape.clone()));
811            if !os_name.is_empty() {
812                metadata.push(("os".to_string(), os_name));
813            }
814            metadata.push(("status".to_string(), instance.lifecycle_state.clone()));
815
816            hosts.push(ProviderHost {
817                server_id: instance.id.clone(),
818                name: instance.display_name.clone(),
819                ip,
820                tags: extract_tags(&instance.freeform_tags),
821                metadata,
822            });
823        }
824
825        if fetch_failures > 0 {
826            if hosts.is_empty() {
827                return Err(ProviderError::Http(format!(
828                    "Failed to fetch details for all {} instances",
829                    total_instances
830                )));
831            }
832            return Err(ProviderError::PartialResult {
833                hosts,
834                failures: fetch_failures,
835                total: total_instances,
836            });
837        }
838
839        Ok(hosts)
840    }
841}
842
843/// Minimal percent-encoding for query parameter values (delegates to shared implementation).
844fn urlencoding_encode(input: &str) -> String {
845    super::percent_encode(input)
846}
847
848// ---------------------------------------------------------------------------
849// Tests
850// ---------------------------------------------------------------------------
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    // -----------------------------------------------------------------------
857    // Config parsing and compartment validation
858    // -----------------------------------------------------------------------
859
860    fn minimal_config() -> &'static str {
861        "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\nregion=us-ashburn-1\nkey_file=~/.oci/key.pem\n"
862    }
863
864    #[test]
865    fn test_parse_oci_config_valid() {
866        let creds = parse_oci_config(minimal_config(), "PEM_CONTENT").unwrap();
867        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
868        assert_eq!(creds.user, "ocid1.user.oc1..bbb");
869        assert_eq!(creds.fingerprint, "aa:bb:cc");
870        assert_eq!(creds.region, "us-ashburn-1");
871        assert_eq!(creds.key_pem, "PEM_CONTENT");
872    }
873
874    #[test]
875    fn test_parse_oci_config_missing_tenancy() {
876        let cfg = "[DEFAULT]\nuser=ocid1.user.oc1..bbb\nfingerprint=aa:bb:cc\n";
877        let err = parse_oci_config(cfg, "").unwrap_err();
878        assert!(err.to_string().contains("tenancy"));
879    }
880
881    #[test]
882    fn test_parse_oci_config_missing_user() {
883        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nfingerprint=aa:bb:cc\n";
884        let err = parse_oci_config(cfg, "").unwrap_err();
885        assert!(err.to_string().contains("user"));
886    }
887
888    #[test]
889    fn test_parse_oci_config_missing_fingerprint() {
890        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=ocid1.user.oc1..bbb\n";
891        let err = parse_oci_config(cfg, "").unwrap_err();
892        assert!(err.to_string().contains("fingerprint"));
893    }
894
895    #[test]
896    fn test_parse_oci_config_no_default_profile() {
897        let cfg = "[OTHER]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
898        let err = parse_oci_config(cfg, "").unwrap_err();
899        assert!(err.to_string().contains("tenancy"));
900    }
901
902    #[test]
903    fn test_parse_oci_config_multiple_profiles_reads_default() {
904        let cfg = "[OTHER]\ntenancy=wrong\n[DEFAULT]\ntenancy=right\nuser=u\nfingerprint=f\n";
905        let creds = parse_oci_config(cfg, "").unwrap();
906        assert_eq!(creds.tenancy, "right");
907    }
908
909    #[test]
910    fn test_parse_oci_config_whitespace_trimmed() {
911        let cfg = "[DEFAULT]\n tenancy = ocid1.tenancy.oc1..aaa \n user = u \n fingerprint = f \n";
912        let creds = parse_oci_config(cfg, "").unwrap();
913        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
914        assert_eq!(creds.user, "u");
915        assert_eq!(creds.fingerprint, "f");
916    }
917
918    #[test]
919    fn test_parse_oci_config_crlf() {
920        let cfg = "[DEFAULT]\r\ntenancy=ocid1.tenancy.oc1..aaa\r\nuser=u\r\nfingerprint=f\r\n";
921        let creds = parse_oci_config(cfg, "").unwrap();
922        assert_eq!(creds.tenancy, "ocid1.tenancy.oc1..aaa");
923    }
924
925    #[test]
926    fn test_parse_oci_config_empty_file() {
927        let err = parse_oci_config("", "").unwrap_err();
928        assert!(err.to_string().contains("tenancy"));
929    }
930
931    #[test]
932    fn test_validate_compartment_valid() {
933        assert!(validate_compartment("ocid1.compartment.oc1..aaaaaaaa1234").is_ok());
934    }
935
936    #[test]
937    fn test_validate_compartment_tenancy_accepted() {
938        assert!(validate_compartment("ocid1.tenancy.oc1..aaaaaaaa1234").is_ok());
939    }
940
941    #[test]
942    fn test_validate_compartment_invalid() {
943        assert!(validate_compartment("ocid1.instance.oc1..xxx").is_err());
944        assert!(validate_compartment("not-an-ocid").is_err());
945        assert!(validate_compartment("").is_err());
946    }
947
948    // -----------------------------------------------------------------------
949    // RFC 7231 date formatting
950    // -----------------------------------------------------------------------
951
952    #[test]
953    fn test_format_rfc7231_known_vector() {
954        // 1774526400 = Thu, 26 Mar 2026 12:00:00 GMT
955        assert_eq!(
956            format_rfc7231(1_774_526_400),
957            "Thu, 26 Mar 2026 12:00:00 GMT"
958        );
959    }
960
961    #[test]
962    fn test_format_rfc7231_epoch_zero() {
963        assert_eq!(format_rfc7231(0), "Thu, 01 Jan 1970 00:00:00 GMT");
964    }
965
966    #[test]
967    fn test_format_rfc7231_leap_year() {
968        // 1582934400 = Sat, 29 Feb 2020 00:00:00 GMT
969        assert_eq!(
970            format_rfc7231(1_582_934_400),
971            "Sat, 29 Feb 2020 00:00:00 GMT"
972        );
973    }
974
975    // -----------------------------------------------------------------------
976    // RSA signing
977    // -----------------------------------------------------------------------
978
979    fn load_test_key() -> String {
980        std::fs::read_to_string(concat!(
981            env!("CARGO_MANIFEST_DIR"),
982            "/tests/fixtures/test_oci_key.pem"
983        ))
984        .expect("test key fixture missing")
985    }
986
987    fn load_test_key_pkcs1() -> String {
988        std::fs::read_to_string(concat!(
989            env!("CARGO_MANIFEST_DIR"),
990            "/tests/fixtures/test_oci_key_pkcs1.pem"
991        ))
992        .expect("test pkcs1 key fixture missing")
993    }
994
995    fn make_creds(key_pem: String) -> OciCredentials {
996        OciCredentials {
997            tenancy: "ocid1.tenancy.oc1..aaa".into(),
998            user: "ocid1.user.oc1..bbb".into(),
999            fingerprint: "aa:bb:cc:dd".into(),
1000            key_pem,
1001            region: "us-ashburn-1".into(),
1002        }
1003    }
1004
1005    #[test]
1006    fn test_sign_request_authorization_header_format() {
1007        let creds = make_creds(load_test_key());
1008        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1009        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1010        let result = sign_request(
1011            &creds,
1012            &rsa_key,
1013            date,
1014            "iaas.us-ashburn-1.oraclecloud.com",
1015            "/20160918/instances",
1016        )
1017        .unwrap();
1018        assert!(result.starts_with("Signature version=\"1\",keyId="));
1019        assert!(result.contains("algorithm=\"rsa-sha256\""));
1020        // Exact match on the headers field
1021        assert!(result.contains("headers=\"date (request-target) host\""));
1022        assert!(result.contains("signature=\""));
1023        // Verify keyId format is exactly tenancy/user/fingerprint
1024        let expected_key_id = format!(
1025            "keyId=\"{}/{}/{}\"",
1026            creds.tenancy, creds.user, creds.fingerprint
1027        );
1028        assert!(
1029            result.contains(&expected_key_id),
1030            "keyId mismatch: expected {} in {}",
1031            expected_key_id,
1032            result
1033        );
1034    }
1035
1036    #[test]
1037    fn test_sign_request_deterministic() {
1038        let key = load_test_key();
1039        let creds1 = make_creds(key.clone());
1040        let creds2 = make_creds(key);
1041        let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1042        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1043        let host = "iaas.us-ashburn-1.oraclecloud.com";
1044        let path = "/20160918/instances";
1045        let r1 = sign_request(&creds1, &rsa_key, date, host, path).unwrap();
1046        let r2 = sign_request(&creds2, &rsa_key, date, host, path).unwrap();
1047        assert_eq!(r1, r2);
1048    }
1049
1050    #[test]
1051    fn test_sign_request_different_hosts_differ() {
1052        let key = load_test_key();
1053        let creds1 = make_creds(key.clone());
1054        let creds2 = make_creds(key);
1055        let rsa_key = parse_private_key(&creds1.key_pem).unwrap();
1056        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1057        let path = "/20160918/instances";
1058        let r1 = sign_request(
1059            &creds1,
1060            &rsa_key,
1061            date,
1062            "iaas.us-ashburn-1.oraclecloud.com",
1063            path,
1064        )
1065        .unwrap();
1066        let r2 = sign_request(
1067            &creds2,
1068            &rsa_key,
1069            date,
1070            "iaas.us-phoenix-1.oraclecloud.com",
1071            path,
1072        )
1073        .unwrap();
1074        assert_ne!(r1, r2);
1075    }
1076
1077    #[test]
1078    fn test_parse_private_key_pkcs1() {
1079        let pem = load_test_key_pkcs1();
1080        assert!(parse_private_key(&pem).is_ok());
1081    }
1082
1083    #[test]
1084    fn test_parse_private_key_pkcs8() {
1085        let pem = load_test_key();
1086        assert!(parse_private_key(&pem).is_ok());
1087    }
1088
1089    #[test]
1090    fn test_parse_private_key_encrypted_detected() {
1091        let fake_encrypted = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: ...\ndata\n-----END RSA PRIVATE KEY-----";
1092        let err = parse_private_key(fake_encrypted).unwrap_err();
1093        assert!(err.to_string().to_lowercase().contains("encrypt"));
1094    }
1095
1096    #[test]
1097    fn test_parse_private_key_proc_type_encrypted() {
1098        // Different wording but also contains ENCRYPTED
1099        let pem = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nABC\n-----END RSA PRIVATE KEY-----";
1100        let err = parse_private_key(pem).unwrap_err();
1101        assert!(err.to_string().to_lowercase().contains("encrypt"));
1102    }
1103
1104    #[test]
1105    fn test_parse_private_key_malformed() {
1106        let err = parse_private_key("not a pem key at all").unwrap_err();
1107        assert!(err.to_string().contains("Failed to parse OCI private key"));
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // extract_key_file
1112    // -----------------------------------------------------------------------
1113
1114    #[test]
1115    fn test_extract_key_file_present() {
1116        let cfg = "[DEFAULT]\ntenancy=t\nkey_file=~/.oci/key.pem\n";
1117        assert_eq!(extract_key_file(cfg).unwrap(), "~/.oci/key.pem");
1118    }
1119
1120    #[test]
1121    fn test_extract_key_file_missing() {
1122        let cfg = "[DEFAULT]\ntenancy=t\n";
1123        assert!(extract_key_file(cfg).is_err());
1124    }
1125
1126    // -----------------------------------------------------------------------
1127    // JSON deserialization
1128    // -----------------------------------------------------------------------
1129
1130    #[test]
1131    fn test_deserialize_list_instances() {
1132        let json = r#"[
1133            {
1134                "id": "ocid1.instance.oc1..aaa",
1135                "displayName": "my-server",
1136                "lifecycleState": "RUNNING",
1137                "shape": "VM.Standard2.1",
1138                "imageId": "ocid1.image.oc1..img",
1139                "freeformTags": {"env": "prod", "team": "ops"}
1140            }
1141        ]"#;
1142        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1143        assert_eq!(items.len(), 1);
1144        let inst = &items[0];
1145        assert_eq!(inst.id, "ocid1.instance.oc1..aaa");
1146        assert_eq!(inst.display_name, "my-server");
1147        assert_eq!(inst.shape, "VM.Standard2.1");
1148        let tags = inst.freeform_tags.as_ref().unwrap();
1149        assert_eq!(tags.get("env").map(String::as_str), Some("prod"));
1150        assert_eq!(tags.get("team").map(String::as_str), Some("ops"));
1151    }
1152
1153    #[test]
1154    fn test_deserialize_list_instances_empty() {
1155        let json = r#"[]"#;
1156        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1157        assert_eq!(items.len(), 0);
1158    }
1159
1160    #[test]
1161    fn test_deserialize_list_instances_null_image_id() {
1162        let json = r#"[
1163            {
1164                "id": "ocid1.instance.oc1..bbb",
1165                "displayName": "no-image",
1166                "lifecycleState": "STOPPED",
1167                "shape": "VM.Standard2.1"
1168            }
1169        ]"#;
1170        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1171        assert_eq!(items.len(), 1);
1172        assert!(items[0].image_id.is_none());
1173        assert!(items[0].freeform_tags.is_none());
1174    }
1175
1176    #[test]
1177    fn test_deserialize_vnic_attachment_is_primary() {
1178        let json = r#"[
1179            {
1180                "instanceId": "ocid1.instance.oc1..aaa",
1181                "vnicId": "ocid1.vnic.oc1..vvv",
1182                "lifecycleState": "ATTACHED",
1183                "isPrimary": true
1184            }
1185        ]"#;
1186        let items: Vec<OciVnicAttachment> = serde_json::from_str(json).unwrap();
1187        assert_eq!(items.len(), 1);
1188        let att = &items[0];
1189        assert_eq!(att.instance_id, "ocid1.instance.oc1..aaa");
1190        assert_eq!(att.vnic_id.as_deref(), Some("ocid1.vnic.oc1..vvv"));
1191        assert_eq!(att.lifecycle_state, "ATTACHED");
1192        assert_eq!(att.is_primary, Some(true));
1193    }
1194
1195    #[test]
1196    fn test_deserialize_vnic_public_and_private() {
1197        let json = r#"{"publicIp": "1.2.3.4", "privateIp": "10.0.0.5"}"#;
1198        let vnic: OciVnic = serde_json::from_str(json).unwrap();
1199        assert_eq!(vnic.public_ip.as_deref(), Some("1.2.3.4"));
1200        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1201    }
1202
1203    #[test]
1204    fn test_deserialize_vnic_private_only() {
1205        let json = r#"{"privateIp": "10.0.0.5"}"#;
1206        let vnic: OciVnic = serde_json::from_str(json).unwrap();
1207        assert!(vnic.public_ip.is_none());
1208        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1209    }
1210
1211    #[test]
1212    fn test_deserialize_image() {
1213        let json = r#"{"displayName": "Oracle-Linux-8.9"}"#;
1214        let img: OciImage = serde_json::from_str(json).unwrap();
1215        assert_eq!(img.display_name.as_deref(), Some("Oracle-Linux-8.9"));
1216    }
1217
1218    #[test]
1219    fn test_deserialize_error_body() {
1220        let json = r#"{"code": "NotAuthenticated", "message": "Missing or invalid credentials."}"#;
1221        let err: OciErrorBody = serde_json::from_str(json).unwrap();
1222        assert_eq!(err.code.as_deref(), Some("NotAuthenticated"));
1223        assert_eq!(
1224            err.message.as_deref(),
1225            Some("Missing or invalid credentials.")
1226        );
1227    }
1228
1229    #[test]
1230    fn test_deserialize_error_body_missing_fields() {
1231        let json = r#"{}"#;
1232        let err: OciErrorBody = serde_json::from_str(json).unwrap();
1233        assert!(err.code.is_none());
1234        assert!(err.message.is_none());
1235    }
1236
1237    // -----------------------------------------------------------------------
1238    // IP selection, VNIC mapping, tag extraction
1239    // -----------------------------------------------------------------------
1240
1241    #[test]
1242    fn test_select_ip_public_preferred() {
1243        let vnic = OciVnic {
1244            public_ip: Some("1.2.3.4".to_string()),
1245            private_ip: Some("10.0.0.1".to_string()),
1246        };
1247        assert_eq!(select_ip(&vnic), "1.2.3.4");
1248    }
1249
1250    #[test]
1251    fn test_select_ip_private_fallback() {
1252        let vnic = OciVnic {
1253            public_ip: None,
1254            private_ip: Some("10.0.0.1".to_string()),
1255        };
1256        assert_eq!(select_ip(&vnic), "10.0.0.1");
1257    }
1258
1259    #[test]
1260    fn test_select_ip_empty() {
1261        let vnic = OciVnic {
1262            public_ip: None,
1263            private_ip: None,
1264        };
1265        assert_eq!(select_ip(&vnic), "");
1266    }
1267
1268    #[test]
1269    fn test_select_primary_vnic() {
1270        let attachments = vec![
1271            OciVnicAttachment {
1272                instance_id: "inst-1".to_string(),
1273                vnic_id: Some("vnic-secondary".to_string()),
1274                lifecycle_state: "ATTACHED".to_string(),
1275                is_primary: Some(false),
1276            },
1277            OciVnicAttachment {
1278                instance_id: "inst-1".to_string(),
1279                vnic_id: Some("vnic-primary".to_string()),
1280                lifecycle_state: "ATTACHED".to_string(),
1281                is_primary: Some(true),
1282            },
1283        ];
1284        assert_eq!(
1285            select_vnic_for_instance(&attachments, "inst-1"),
1286            Some("vnic-primary".to_string())
1287        );
1288    }
1289
1290    #[test]
1291    fn test_select_vnic_no_primary_uses_first() {
1292        let attachments = vec![
1293            OciVnicAttachment {
1294                instance_id: "inst-1".to_string(),
1295                vnic_id: Some("vnic-first".to_string()),
1296                lifecycle_state: "ATTACHED".to_string(),
1297                is_primary: None,
1298            },
1299            OciVnicAttachment {
1300                instance_id: "inst-1".to_string(),
1301                vnic_id: Some("vnic-second".to_string()),
1302                lifecycle_state: "ATTACHED".to_string(),
1303                is_primary: None,
1304            },
1305        ];
1306        assert_eq!(
1307            select_vnic_for_instance(&attachments, "inst-1"),
1308            Some("vnic-first".to_string())
1309        );
1310    }
1311
1312    #[test]
1313    fn test_select_vnic_no_attachment() {
1314        let attachments: Vec<OciVnicAttachment> = vec![];
1315        assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1316    }
1317
1318    #[test]
1319    fn test_select_vnic_filters_by_instance_id() {
1320        let attachments = vec![OciVnicAttachment {
1321            instance_id: "inst-other".to_string(),
1322            vnic_id: Some("vnic-other".to_string()),
1323            lifecycle_state: "ATTACHED".to_string(),
1324            is_primary: Some(true),
1325        }];
1326        assert_eq!(select_vnic_for_instance(&attachments, "inst-1"), None);
1327    }
1328
1329    #[test]
1330    fn test_extract_freeform_tags() {
1331        let mut map = std::collections::HashMap::new();
1332        map.insert("env".to_string(), "prod".to_string());
1333        map.insert("role".to_string(), "".to_string());
1334        map.insert("team".to_string(), "ops".to_string());
1335        let tags = extract_tags(&Some(map));
1336        // sorted
1337        assert_eq!(tags, vec!["env:prod", "role", "team:ops"]);
1338    }
1339
1340    #[test]
1341    fn test_extract_freeform_tags_empty() {
1342        let tags = extract_tags(&None);
1343        assert!(tags.is_empty());
1344    }
1345
1346    // -----------------------------------------------------------------------
1347    // Region constants and Provider trait
1348    // -----------------------------------------------------------------------
1349
1350    #[test]
1351    fn test_oci_regions_count() {
1352        assert_eq!(OCI_REGIONS.len(), 38);
1353    }
1354
1355    #[test]
1356    fn test_oci_regions_no_duplicates() {
1357        let mut ids: Vec<&str> = OCI_REGIONS.iter().map(|(id, _)| *id).collect();
1358        ids.sort_unstable();
1359        let before = ids.len();
1360        ids.dedup();
1361        assert_eq!(ids.len(), before, "duplicate region IDs found");
1362    }
1363
1364    #[test]
1365    fn test_oci_region_groups_cover_all() {
1366        use std::collections::HashSet;
1367        let group_indices: HashSet<usize> = OCI_REGION_GROUPS
1368            .iter()
1369            .flat_map(|(_, s, e)| *s..*e)
1370            .collect();
1371        let all_indices: HashSet<usize> = (0..OCI_REGIONS.len()).collect();
1372        assert_eq!(
1373            group_indices, all_indices,
1374            "region groups must cover all region indices exactly"
1375        );
1376        for (_, start, end) in OCI_REGION_GROUPS {
1377            assert!(*end <= OCI_REGIONS.len());
1378            assert!(start < end);
1379        }
1380    }
1381
1382    #[test]
1383    fn test_oracle_provider_name() {
1384        let oracle = Oracle {
1385            regions: Vec::new(),
1386            compartment: String::new(),
1387        };
1388        assert_eq!(oracle.name(), "oracle");
1389        assert_eq!(oracle.short_label(), "oci");
1390    }
1391
1392    #[test]
1393    fn test_oracle_empty_compartment_error() {
1394        let oracle = Oracle {
1395            regions: Vec::new(),
1396            compartment: String::new(),
1397        };
1398        let cancel = AtomicBool::new(false);
1399        let err = oracle
1400            .fetch_hosts_with_progress("some_token", &cancel, &|_| {})
1401            .unwrap_err();
1402        assert!(err.to_string().contains("compartment"));
1403    }
1404
1405    // -----------------------------------------------------------------------
1406    // Additional coverage
1407    // -----------------------------------------------------------------------
1408
1409    #[test]
1410    fn test_malformed_json_instance_list() {
1411        let result = serde_json::from_str::<Vec<OciInstance>>("not json");
1412        assert!(result.is_err());
1413    }
1414
1415    #[test]
1416    fn test_parse_private_key_empty_string() {
1417        let err = parse_private_key("").unwrap_err();
1418        assert!(
1419            err.to_string().contains("Failed to parse OCI private key"),
1420            "got: {}",
1421            err
1422        );
1423    }
1424
1425    #[test]
1426    fn test_parse_oci_config_missing_region_defaults_empty() {
1427        let cfg = "[DEFAULT]\ntenancy=ocid1.tenancy.oc1..aaa\nuser=u\nfingerprint=f\n";
1428        let creds = parse_oci_config(cfg, "").unwrap();
1429        assert_eq!(creds.region, "");
1430    }
1431
1432    #[test]
1433    fn test_sign_request_headers_exact() {
1434        let creds = make_creds(load_test_key());
1435        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1436        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1437        let result = sign_request(
1438            &creds,
1439            &rsa_key,
1440            date,
1441            "iaas.us-ashburn-1.oraclecloud.com",
1442            "/20160918/instances",
1443        )
1444        .unwrap();
1445        // Exact match on the headers= field value
1446        assert!(
1447            result.contains("headers=\"date (request-target) host\""),
1448            "headers field mismatch in: {}",
1449            result
1450        );
1451    }
1452
1453    #[test]
1454    fn test_sign_request_key_id_format() {
1455        let creds = make_creds(load_test_key());
1456        let rsa_key = parse_private_key(&creds.key_pem).unwrap();
1457        let date = "Thu, 26 Mar 2026 12:00:00 GMT";
1458        let result = sign_request(
1459            &creds,
1460            &rsa_key,
1461            date,
1462            "iaas.us-ashburn-1.oraclecloud.com",
1463            "/20160918/instances",
1464        )
1465        .unwrap();
1466        let expected = format!(
1467            "keyId=\"{}/{}/{}\"",
1468            creds.tenancy, creds.user, creds.fingerprint
1469        );
1470        assert!(
1471            result.contains(&expected),
1472            "expected keyId {} in: {}",
1473            expected,
1474            result
1475        );
1476    }
1477
1478    #[test]
1479    fn test_deserialize_multiple_instances() {
1480        let json = r#"[
1481            {
1482                "id": "ocid1.instance.oc1..aaa",
1483                "displayName": "server-1",
1484                "lifecycleState": "RUNNING",
1485                "shape": "VM.Standard2.1"
1486            },
1487            {
1488                "id": "ocid1.instance.oc1..bbb",
1489                "displayName": "server-2",
1490                "lifecycleState": "STOPPED",
1491                "shape": "VM.Standard.E4.Flex",
1492                "imageId": "ocid1.image.oc1..img2"
1493            }
1494        ]"#;
1495        let items: Vec<OciInstance> = serde_json::from_str(json).unwrap();
1496        assert_eq!(items.len(), 2);
1497        assert_eq!(items[0].id, "ocid1.instance.oc1..aaa");
1498        assert_eq!(items[0].display_name, "server-1");
1499        assert_eq!(items[1].id, "ocid1.instance.oc1..bbb");
1500        assert_eq!(items[1].display_name, "server-2");
1501        assert_eq!(items[1].image_id.as_deref(), Some("ocid1.image.oc1..img2"));
1502    }
1503
1504    #[test]
1505    fn test_extract_freeform_tags_special_chars() {
1506        let mut map = std::collections::HashMap::new();
1507        map.insert("path".to_string(), "/usr/local/bin".to_string());
1508        map.insert("env:tier".to_string(), "prod/us-east".to_string());
1509        let tags = extract_tags(&Some(map));
1510        assert!(tags.contains(&"env:tier:prod/us-east".to_string()));
1511        assert!(tags.contains(&"path:/usr/local/bin".to_string()));
1512    }
1513
1514    // -----------------------------------------------------------------------
1515    // HTTP roundtrip tests (mockito)
1516    // -----------------------------------------------------------------------
1517
1518    #[test]
1519    fn test_http_list_instances_roundtrip() {
1520        let mut server = mockito::Server::new();
1521        let mock = server
1522            .mock("GET", "/20160918/instances")
1523            .match_query(mockito::Matcher::AllOf(vec![
1524                mockito::Matcher::UrlEncoded(
1525                    "compartmentId".into(),
1526                    "ocid1.compartment.oc1..aaa".into(),
1527                ),
1528                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1529            ]))
1530            .match_header("Authorization", mockito::Matcher::Any)
1531            .with_status(200)
1532            .with_header("content-type", "application/json")
1533            .with_body(
1534                r#"[
1535                    {
1536                        "id": "ocid1.instance.oc1..inst1",
1537                        "displayName": "web-prod-1",
1538                        "lifecycleState": "RUNNING",
1539                        "shape": "VM.Standard2.1",
1540                        "imageId": "ocid1.image.oc1..img1",
1541                        "freeformTags": {"env": "prod", "team": "web"}
1542                    }
1543                ]"#,
1544            )
1545            .create();
1546
1547        let agent = super::super::http_agent();
1548        let url = format!(
1549            "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1550            server.url()
1551        );
1552        let items: Vec<OciInstance> = agent
1553            .get(&url)
1554            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1555            .call()
1556            .unwrap()
1557            .body_mut()
1558            .read_json()
1559            .unwrap();
1560
1561        assert_eq!(items.len(), 1);
1562        assert_eq!(items[0].id, "ocid1.instance.oc1..inst1");
1563        assert_eq!(items[0].display_name, "web-prod-1");
1564        assert_eq!(items[0].lifecycle_state, "RUNNING");
1565        assert_eq!(items[0].shape, "VM.Standard2.1");
1566        assert_eq!(items[0].image_id.as_deref(), Some("ocid1.image.oc1..img1"));
1567        let tags = items[0].freeform_tags.as_ref().unwrap();
1568        assert_eq!(tags.get("env").unwrap(), "prod");
1569        assert_eq!(tags.get("team").unwrap(), "web");
1570        mock.assert();
1571    }
1572
1573    #[test]
1574    fn test_http_list_instances_pagination() {
1575        let mut server = mockito::Server::new();
1576        let page1 = server
1577            .mock("GET", "/20160918/instances")
1578            .match_query(mockito::Matcher::AllOf(vec![
1579                mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1580                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1581            ]))
1582            .with_status(200)
1583            .with_header("content-type", "application/json")
1584            .with_header("opc-next-page", "page2-token")
1585            .with_body(
1586                r#"[{"id": "ocid1.instance.oc1..a", "displayName": "srv-1", "lifecycleState": "RUNNING", "shape": "VM.Standard2.1"}]"#,
1587            )
1588            .create();
1589        let page2 = server
1590            .mock("GET", "/20160918/instances")
1591            .match_query(mockito::Matcher::AllOf(vec![
1592                mockito::Matcher::UrlEncoded("compartmentId".into(), "ocid1.compartment.oc1..aaa".into()),
1593                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1594                mockito::Matcher::UrlEncoded("page".into(), "page2-token".into()),
1595            ]))
1596            .with_status(200)
1597            .with_header("content-type", "application/json")
1598            .with_body(
1599                r#"[{"id": "ocid1.instance.oc1..b", "displayName": "srv-2", "lifecycleState": "STOPPED", "shape": "VM.Standard.E4.Flex"}]"#,
1600            )
1601            .create();
1602
1603        let agent = super::super::http_agent();
1604        // Page 1
1605        let resp1 = agent
1606            .get(&format!(
1607                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1608                server.url()
1609            ))
1610            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1611            .call()
1612            .unwrap();
1613        let next_page = resp1
1614            .headers()
1615            .get("opc-next-page")
1616            .and_then(|v| v.to_str().ok())
1617            .map(String::from);
1618        let items1: Vec<OciInstance> =
1619            serde_json::from_str(&resp1.into_body().read_to_string().unwrap()).unwrap();
1620        assert_eq!(items1.len(), 1);
1621        assert_eq!(items1[0].id, "ocid1.instance.oc1..a");
1622        assert_eq!(next_page.as_deref(), Some("page2-token"));
1623
1624        // Page 2
1625        let items2: Vec<OciInstance> = agent
1626            .get(&format!(
1627                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100&page=page2-token",
1628                server.url()
1629            ))
1630            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1631            .call()
1632            .unwrap()
1633            .body_mut()
1634            .read_json()
1635            .unwrap();
1636        assert_eq!(items2.len(), 1);
1637        assert_eq!(items2[0].id, "ocid1.instance.oc1..b");
1638
1639        page1.assert();
1640        page2.assert();
1641    }
1642
1643    #[test]
1644    fn test_http_list_instances_auth_failure() {
1645        let mut server = mockito::Server::new();
1646        let mock = server
1647            .mock("GET", "/20160918/instances")
1648            .match_query(mockito::Matcher::Any)
1649            .with_status(401)
1650            .with_body(r#"{"code": "NotAuthenticated", "message": "The required information to complete authentication was not provided."}"#)
1651            .create();
1652
1653        let agent = super::super::http_agent();
1654        let result = agent
1655            .get(&format!(
1656                "{}/20160918/instances?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1657                server.url()
1658            ))
1659            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1660            .call();
1661
1662        match result {
1663            Err(ureq::Error::StatusCode(401)) => {} // expected
1664            other => panic!("expected 401 error, got {:?}", other),
1665        }
1666        mock.assert();
1667    }
1668
1669    #[test]
1670    fn test_http_vnic_attachments_roundtrip() {
1671        let mut server = mockito::Server::new();
1672        let mock = server
1673            .mock("GET", "/20160918/vnicAttachments")
1674            .match_query(mockito::Matcher::AllOf(vec![
1675                mockito::Matcher::UrlEncoded(
1676                    "compartmentId".into(),
1677                    "ocid1.compartment.oc1..aaa".into(),
1678                ),
1679                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1680            ]))
1681            .match_header("Authorization", mockito::Matcher::Any)
1682            .with_status(200)
1683            .with_header("content-type", "application/json")
1684            .with_body(
1685                r#"[
1686                    {
1687                        "instanceId": "ocid1.instance.oc1..inst1",
1688                        "vnicId": "ocid1.vnic.oc1..vnic1",
1689                        "lifecycleState": "ATTACHED",
1690                        "isPrimary": true
1691                    },
1692                    {
1693                        "instanceId": "ocid1.instance.oc1..inst1",
1694                        "vnicId": "ocid1.vnic.oc1..vnic2",
1695                        "lifecycleState": "ATTACHED",
1696                        "isPrimary": false
1697                    }
1698                ]"#,
1699            )
1700            .create();
1701
1702        let agent = super::super::http_agent();
1703        let url = format!(
1704            "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1705            server.url()
1706        );
1707        let items: Vec<OciVnicAttachment> = agent
1708            .get(&url)
1709            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1710            .call()
1711            .unwrap()
1712            .body_mut()
1713            .read_json()
1714            .unwrap();
1715
1716        assert_eq!(items.len(), 2);
1717        assert_eq!(items[0].instance_id, "ocid1.instance.oc1..inst1");
1718        assert_eq!(items[0].vnic_id.as_deref(), Some("ocid1.vnic.oc1..vnic1"));
1719        assert_eq!(items[0].lifecycle_state, "ATTACHED");
1720        assert_eq!(items[0].is_primary, Some(true));
1721        assert_eq!(items[1].is_primary, Some(false));
1722        mock.assert();
1723    }
1724
1725    #[test]
1726    fn test_http_vnic_attachments_auth_failure() {
1727        let mut server = mockito::Server::new();
1728        let mock = server
1729            .mock("GET", "/20160918/vnicAttachments")
1730            .match_query(mockito::Matcher::Any)
1731            .with_status(401)
1732            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1733            .create();
1734
1735        let agent = super::super::http_agent();
1736        let result = agent
1737            .get(&format!(
1738                "{}/20160918/vnicAttachments?compartmentId=ocid1.compartment.oc1..aaa&limit=100",
1739                server.url()
1740            ))
1741            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1742            .call();
1743
1744        match result {
1745            Err(ureq::Error::StatusCode(401)) => {} // expected
1746            other => panic!("expected 401 error, got {:?}", other),
1747        }
1748        mock.assert();
1749    }
1750
1751    #[test]
1752    fn test_http_get_vnic_roundtrip() {
1753        let mut server = mockito::Server::new();
1754        let mock = server
1755            .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1756            .match_header("Authorization", mockito::Matcher::Any)
1757            .with_status(200)
1758            .with_header("content-type", "application/json")
1759            .with_body(r#"{"publicIp": "129.146.10.1", "privateIp": "10.0.0.5"}"#)
1760            .create();
1761
1762        let agent = super::super::http_agent();
1763        let url = format!("{}/20160918/vnics/ocid1.vnic.oc1..vnic1", server.url());
1764        let vnic: OciVnic = agent
1765            .get(&url)
1766            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1767            .call()
1768            .unwrap()
1769            .body_mut()
1770            .read_json()
1771            .unwrap();
1772
1773        assert_eq!(vnic.public_ip.as_deref(), Some("129.146.10.1"));
1774        assert_eq!(vnic.private_ip.as_deref(), Some("10.0.0.5"));
1775        mock.assert();
1776    }
1777
1778    #[test]
1779    fn test_http_get_vnic_auth_failure() {
1780        let mut server = mockito::Server::new();
1781        let mock = server
1782            .mock("GET", "/20160918/vnics/ocid1.vnic.oc1..vnic1")
1783            .match_query(mockito::Matcher::Any)
1784            .with_status(401)
1785            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1786            .create();
1787
1788        let agent = super::super::http_agent();
1789        let result = agent
1790            .get(&format!(
1791                "{}/20160918/vnics/ocid1.vnic.oc1..vnic1",
1792                server.url()
1793            ))
1794            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1795            .call();
1796
1797        match result {
1798            Err(ureq::Error::StatusCode(401)) => {} // expected
1799            other => panic!("expected 401 error, got {:?}", other),
1800        }
1801        mock.assert();
1802    }
1803
1804    #[test]
1805    fn test_http_get_image_roundtrip() {
1806        let mut server = mockito::Server::new();
1807        let mock = server
1808            .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1809            .match_header("Authorization", mockito::Matcher::Any)
1810            .with_status(200)
1811            .with_header("content-type", "application/json")
1812            .with_body(r#"{"displayName": "Oracle-Linux-8.8-2024.01.26-0"}"#)
1813            .create();
1814
1815        let agent = super::super::http_agent();
1816        let url = format!("{}/20160918/images/ocid1.image.oc1..img1", server.url());
1817        let image: OciImage = agent
1818            .get(&url)
1819            .header("Authorization", "Signature version=\"1\",keyId=\"fake\"")
1820            .call()
1821            .unwrap()
1822            .body_mut()
1823            .read_json()
1824            .unwrap();
1825
1826        assert_eq!(
1827            image.display_name.as_deref(),
1828            Some("Oracle-Linux-8.8-2024.01.26-0")
1829        );
1830        mock.assert();
1831    }
1832
1833    #[test]
1834    fn test_http_get_image_auth_failure() {
1835        let mut server = mockito::Server::new();
1836        let mock = server
1837            .mock("GET", "/20160918/images/ocid1.image.oc1..img1")
1838            .match_query(mockito::Matcher::Any)
1839            .with_status(401)
1840            .with_body(r#"{"code": "NotAuthenticated", "message": "Invalid credentials"}"#)
1841            .create();
1842
1843        let agent = super::super::http_agent();
1844        let result = agent
1845            .get(&format!(
1846                "{}/20160918/images/ocid1.image.oc1..img1",
1847                server.url()
1848            ))
1849            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1850            .call();
1851
1852        match result {
1853            Err(ureq::Error::StatusCode(401)) => {} // expected
1854            other => panic!("expected 401 error, got {:?}", other),
1855        }
1856        mock.assert();
1857    }
1858
1859    // ── ListCompartments HTTP tests ─────────────────────────────────
1860
1861    #[test]
1862    fn test_deserialize_compartment() {
1863        let json = r#"[
1864            {"id": "ocid1.compartment.oc1..child1", "lifecycleState": "ACTIVE"},
1865            {"id": "ocid1.compartment.oc1..child2", "lifecycleState": "DELETED"}
1866        ]"#;
1867        let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1868        assert_eq!(items.len(), 2);
1869        assert_eq!(items[0].id, "ocid1.compartment.oc1..child1");
1870        assert_eq!(items[0].lifecycle_state, "ACTIVE");
1871        assert_eq!(items[1].lifecycle_state, "DELETED");
1872    }
1873
1874    #[test]
1875    fn test_deserialize_compartment_empty() {
1876        let json = r#"[]"#;
1877        let items: Vec<OciCompartment> = serde_json::from_str(json).unwrap();
1878        assert_eq!(items.len(), 0);
1879    }
1880
1881    #[test]
1882    fn test_http_list_compartments_roundtrip() {
1883        let mut server = mockito::Server::new();
1884        let mock = server
1885            .mock("GET", "/20160918/compartments")
1886            .match_query(mockito::Matcher::AllOf(vec![
1887                mockito::Matcher::UrlEncoded(
1888                    "compartmentId".into(),
1889                    "ocid1.tenancy.oc1..root".into(),
1890                ),
1891                mockito::Matcher::UrlEncoded("compartmentIdInSubtree".into(), "true".into()),
1892                mockito::Matcher::UrlEncoded("lifecycleState".into(), "ACTIVE".into()),
1893                mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
1894            ]))
1895            .with_status(200)
1896            .with_header("content-type", "application/json")
1897            .with_body(
1898                r#"[
1899                    {"id": "ocid1.compartment.oc1..prod", "lifecycleState": "ACTIVE"},
1900                    {"id": "ocid1.compartment.oc1..staging", "lifecycleState": "ACTIVE"},
1901                    {"id": "ocid1.compartment.oc1..old", "lifecycleState": "DELETED"}
1902                ]"#,
1903            )
1904            .create();
1905
1906        let agent = super::super::http_agent();
1907        let url = format!(
1908            "{}/20160918/compartments?compartmentId={}&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1909            server.url(),
1910            "ocid1.tenancy.oc1..root"
1911        );
1912        let items: Vec<OciCompartment> = agent
1913            .get(&url)
1914            .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
1915            .header("Authorization", "Signature version=\"1\",keyId=\"test\"")
1916            .call()
1917            .unwrap()
1918            .body_mut()
1919            .read_json()
1920            .unwrap();
1921
1922        // Only ACTIVE compartments should be kept by caller
1923        let active: Vec<_> = items
1924            .iter()
1925            .filter(|c| c.lifecycle_state == "ACTIVE")
1926            .collect();
1927        assert_eq!(active.len(), 2);
1928        assert_eq!(active[0].id, "ocid1.compartment.oc1..prod");
1929        assert_eq!(active[1].id, "ocid1.compartment.oc1..staging");
1930        mock.assert();
1931    }
1932
1933    #[test]
1934    fn test_http_list_compartments_auth_failure() {
1935        let mut server = mockito::Server::new();
1936        let mock = server
1937            .mock("GET", "/20160918/compartments")
1938            .match_query(mockito::Matcher::Any)
1939            .with_status(401)
1940            .with_body(r#"{"code": "NotAuthenticated", "message": "Not authenticated"}"#)
1941            .create();
1942
1943        let agent = super::super::http_agent();
1944        let result = agent
1945            .get(&format!(
1946                "{}/20160918/compartments?compartmentId=x&compartmentIdInSubtree=true&lifecycleState=ACTIVE&limit=100",
1947                server.url()
1948            ))
1949            .header("date", "Thu, 27 Mar 2026 12:00:00 GMT")
1950            .header("Authorization", "Signature version=\"1\",keyId=\"bad\"")
1951            .call();
1952
1953        match result {
1954            Err(ureq::Error::StatusCode(401)) => {} // expected
1955            other => panic!("expected 401 error, got {:?}", other),
1956        }
1957        mock.assert();
1958    }
1959}