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