1use std::io;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use crate::fs_util;
6use crate::providers::ProviderKind;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct ProviderConfigId {
23 pub(crate) provider: String,
24 pub(crate) label: Option<String>,
25}
26
27impl ProviderConfigId {
28 pub fn bare(provider: impl Into<String>) -> Self {
29 Self {
30 provider: provider.into(),
31 label: None,
32 }
33 }
34
35 pub fn labeled(provider: impl Into<String>, label: impl Into<String>) -> Self {
36 Self {
37 provider: provider.into(),
38 label: Some(label.into()),
39 }
40 }
41
42 pub fn kind(&self) -> Option<ProviderKind> {
44 self.provider.parse().ok()
45 }
46}
47
48impl Default for ProviderConfigId {
49 fn default() -> Self {
50 Self::bare(String::new())
51 }
52}
53
54impl std::fmt::Display for ProviderConfigId {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match &self.label {
57 None => f.write_str(&self.provider),
58 Some(l) => write!(f, "{}:{}", self.provider, l),
59 }
60 }
61}
62
63impl FromStr for ProviderConfigId {
64 type Err = String;
65
66 fn from_str(s: &str) -> Result<Self, Self::Err> {
67 match s.split_once(':') {
68 Some((p, l)) => {
69 if p.is_empty() {
70 return Err("provider name is empty".to_string());
71 }
72 validate_label(l)?;
73 Ok(Self::labeled(p, l))
74 }
75 None => {
76 if s.is_empty() {
77 return Err("provider name is empty".to_string());
78 }
79 Ok(Self::bare(s))
80 }
81 }
82 }
83}
84
85pub fn validate_label(label: &str) -> Result<(), String> {
88 if label.is_empty() {
89 return Err("label is empty".to_string());
90 }
91 if label.len() > 32 {
92 return Err("label exceeds 32 characters".to_string());
93 }
94 if !label
95 .chars()
96 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
97 {
98 return Err("label contains illegal characters (only [a-z0-9-] allowed)".to_string());
99 }
100 if label.starts_with('-') || label.ends_with('-') {
101 return Err("label must not start or end with a dash".to_string());
102 }
103 Ok(())
104}
105
106#[derive(Clone)]
108pub struct ProviderSection {
109 pub id: ProviderConfigId,
110 pub token: String,
111 pub alias_prefix: String,
112 pub user: String,
113 pub identity_file: String,
114 pub url: String,
115 pub verify_tls: bool,
116 pub auto_sync: bool,
117 pub profile: String,
118 pub regions: String,
119 pub project: String,
120 pub compartment: String,
121 pub filter: String,
123 pub vault_role: String,
124 pub vault_addr: String,
128}
129
130impl ProviderSection {
131 pub fn provider(&self) -> &str {
133 &self.id.provider
134 }
135
136 pub fn log_record(&self) -> String {
142 let mut parts = vec![
143 format!("prefix='{}'", self.alias_prefix),
144 format!("user='{}'", self.user),
145 format!(
149 "token={}",
150 if self.token.trim().is_empty() {
151 "empty"
152 } else {
153 "set"
154 }
155 ),
156 ];
157 for (name, value) in [
158 ("url", &self.url),
159 ("profile", &self.profile),
160 ("regions", &self.regions),
161 ("project", &self.project),
162 ("compartment", &self.compartment),
163 ("filter", &self.filter),
164 ("identity_file", &self.identity_file),
165 ("vault_role", &self.vault_role),
166 ] {
167 if !value.is_empty() {
168 parts.push(format!("{}='{}'", name, value));
169 }
170 }
171 parts.push(format!("verify_tls={}", self.verify_tls));
172 parts.push(format!("auto_sync={}", self.auto_sync));
173 parts.join(" ")
174 }
175}
176
177impl std::fmt::Debug for ProviderSection {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("ProviderSection")
183 .field("id", &self.id)
184 .field("token", &redacted(&self.token))
185 .field("alias_prefix", &self.alias_prefix)
186 .field("user", &self.user)
187 .field("identity_file", &self.identity_file)
188 .field("url", &self.url)
189 .field("verify_tls", &self.verify_tls)
190 .field("auto_sync", &self.auto_sync)
191 .field("profile", &self.profile)
192 .field("regions", &self.regions)
193 .field("project", &self.project)
194 .field("compartment", &self.compartment)
195 .field("filter", &self.filter)
196 .field("vault_role", &self.vault_role)
197 .field("vault_addr", &redacted(&self.vault_addr))
198 .finish()
199 }
200}
201
202fn redacted(value: &str) -> &'static str {
203 if value.is_empty() {
204 "<empty>"
205 } else {
206 "<redacted>"
207 }
208}
209
210impl Default for ProviderSection {
211 fn default() -> Self {
212 Self {
213 id: ProviderConfigId::default(),
214 token: String::new(),
215 alias_prefix: String::new(),
216 user: String::new(),
217 identity_file: String::new(),
218 url: String::new(),
219 verify_tls: true,
222 auto_sync: false,
223 profile: String::new(),
224 regions: String::new(),
225 project: String::new(),
226 compartment: String::new(),
227 filter: String::new(),
228 vault_role: String::new(),
229 vault_addr: String::new(),
230 }
231 }
232}
233
234fn default_auto_sync(provider: &str) -> bool {
237 provider
238 .parse::<ProviderKind>()
239 .ok()
240 .is_none_or(ProviderKind::default_auto_sync)
241}
242
243#[derive(Debug, Clone, Default)]
245pub struct ProviderConfig {
246 pub sections: Vec<ProviderSection>,
247 pub path_override: Option<PathBuf>,
250}
251
252fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
253 paths.map(crate::runtime::env::Paths::providers_config)
254}
255
256impl ProviderConfig {
257 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
263 let path = match config_path(paths) {
264 Some(p) => p,
265 None => return Self::default(),
266 };
267 let content = match std::fs::read_to_string(&path) {
268 Ok(c) => c,
269 Err(e) if e.kind() == io::ErrorKind::NotFound => {
270 return Self {
271 path_override: Some(path),
272 ..Self::default()
273 };
274 }
275 Err(e) => {
276 log::warn!("[config] Could not read {}: {}", path.display(), e);
277 return Self {
278 path_override: Some(path),
279 ..Self::default()
280 };
281 }
282 };
283 Self {
284 path_override: Some(path),
285 ..Self::parse(&content)
286 }
287 }
288
289 pub(crate) fn parse(content: &str) -> Self {
295 let mut sections: Vec<ProviderSection> = Vec::new();
296 let mut current: Option<ProviderSection> = None;
297
298 for line in content.lines() {
299 let trimmed = line.trim();
300 if trimmed.is_empty() || trimmed.starts_with('#') {
301 continue;
302 }
303 if trimmed.starts_with('[') && trimmed.ends_with(']') {
304 if let Some(section) = current.take() {
305 if !sections.iter().any(|s| s.id == section.id) {
306 sections.push(section);
307 }
308 }
309 let raw = trimmed[1..trimmed.len() - 1].trim();
310 let id = match ProviderConfigId::from_str(raw) {
311 Ok(id) => id,
312 Err(e) => {
313 log::warn!("[config] Skipping invalid section header [{}]: {}", raw, e);
314 current = None;
315 continue;
316 }
317 };
318 if sections.iter().any(|s| s.id == id) {
320 log::warn!("[config] Skipping duplicate section header [{}]", id);
321 current = None;
322 continue;
323 }
324 let has_bare = sections
326 .iter()
327 .any(|s| s.id.provider == id.provider && s.id.label.is_none());
328 let has_labeled = sections
329 .iter()
330 .any(|s| s.id.provider == id.provider && s.id.label.is_some());
331 if (id.label.is_some() && has_bare) || (id.label.is_none() && has_labeled) {
332 log::warn!(
333 "[config] Skipping [{}]: mixing bare and labeled sections for provider '{}' is not allowed",
334 id,
335 id.provider
336 );
337 current = None;
338 continue;
339 }
340 let short_label = super::get_provider(&id.provider)
341 .map(|p| p.short_label().to_string())
342 .unwrap_or_else(|| id.provider.clone());
343 let auto_sync_default = default_auto_sync(&id.provider);
344 let alias_prefix = match &id.label {
345 Some(l) => format!("{}-{}", short_label, l),
346 None => short_label,
347 };
348 current = Some(ProviderSection {
349 id,
350 token: String::new(),
351 alias_prefix,
352 user: "root".to_string(),
353 identity_file: String::new(),
354 url: String::new(),
355 verify_tls: true,
356 auto_sync: auto_sync_default,
357 profile: String::new(),
358 regions: String::new(),
359 project: String::new(),
360 compartment: String::new(),
361 filter: String::new(),
362 vault_role: String::new(),
363 vault_addr: String::new(),
364 });
365 } else if let Some(ref mut section) = current {
366 if let Some((key, value)) = trimmed.split_once('=') {
367 let key = key.trim();
368 let value = value.trim().to_string();
369 match key {
370 "token" => section.token = value,
371 "alias_prefix" => section.alias_prefix = value,
372 "user" => section.user = value,
373 "key" => section.identity_file = value,
374 "url" => section.url = value,
375 "verify_tls" => {
376 section.verify_tls =
377 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
378 }
379 "auto_sync" => {
380 section.auto_sync =
381 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
382 }
383 "profile" => section.profile = value,
384 "regions" => section.regions = value,
385 "project" => section.project = value,
386 "compartment" => section.compartment = value,
387 "filter" => section.filter = value,
388 "vault_role" => {
389 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
391 value
392 } else {
393 String::new()
394 };
395 }
396 "vault_addr" => {
397 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
401 value
402 } else {
403 String::new()
404 };
405 }
406 _ => {}
407 }
408 }
409 }
410 }
411 if let Some(section) = current {
412 if !sections.iter().any(|s| s.id == section.id) {
413 sections.push(section);
414 }
415 }
416 Self {
417 sections,
418 path_override: None,
419 }
420 }
421
422 fn sanitize_value(s: &str) -> String {
425 s.chars().filter(|c| !c.is_control()).collect()
426 }
427
428 pub fn save(&self) -> io::Result<()> {
431 if let Err(e) = self.validate() {
433 log::warn!("[config] Refusing to save invalid provider config: {}", e);
434 return Err(io::Error::new(io::ErrorKind::InvalidData, e));
435 }
436 if crate::demo_flag::is_demo() {
442 #[cfg(not(test))]
443 return Ok(());
444 }
445 let Some(path) = self.path_override.clone() else {
446 return Err(io::Error::new(
447 io::ErrorKind::NotFound,
448 "Could not determine home directory",
449 ));
450 };
451
452 let mut content = String::new();
453 for (i, section) in self.sections.iter().enumerate() {
454 if i > 0 {
455 content.push('\n');
456 }
457 content.push_str(&format!(
458 "[{}]\n",
459 Self::sanitize_value(§ion.id.to_string())
460 ));
461 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
462 content.push_str(&format!(
463 "alias_prefix={}\n",
464 Self::sanitize_value(§ion.alias_prefix)
465 ));
466 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
467 if !section.identity_file.is_empty() {
468 content.push_str(&format!(
469 "key={}\n",
470 Self::sanitize_value(§ion.identity_file)
471 ));
472 }
473 if !section.url.is_empty() {
474 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
475 }
476 if !section.verify_tls {
477 content.push_str("verify_tls=false\n");
478 }
479 if !section.profile.is_empty() {
480 content.push_str(&format!(
481 "profile={}\n",
482 Self::sanitize_value(§ion.profile)
483 ));
484 }
485 if !section.regions.is_empty() {
486 content.push_str(&format!(
487 "regions={}\n",
488 Self::sanitize_value(§ion.regions)
489 ));
490 }
491 if !section.project.is_empty() {
492 content.push_str(&format!(
493 "project={}\n",
494 Self::sanitize_value(§ion.project)
495 ));
496 }
497 if !section.compartment.is_empty() {
498 content.push_str(&format!(
499 "compartment={}\n",
500 Self::sanitize_value(§ion.compartment)
501 ));
502 }
503 if !section.filter.is_empty() {
504 content.push_str(&format!(
505 "filter={}\n",
506 Self::sanitize_value(§ion.filter)
507 ));
508 }
509 if !section.vault_role.is_empty()
510 && crate::vault_ssh::is_valid_role(§ion.vault_role)
511 {
512 content.push_str(&format!(
513 "vault_role={}\n",
514 Self::sanitize_value(§ion.vault_role)
515 ));
516 }
517 if !section.vault_addr.is_empty()
518 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
519 {
520 content.push_str(&format!(
521 "vault_addr={}\n",
522 Self::sanitize_value(§ion.vault_addr)
523 ));
524 }
525 if section.auto_sync != default_auto_sync(§ion.id.provider) {
526 content.push_str(if section.auto_sync {
527 "auto_sync=true\n"
528 } else {
529 "auto_sync=false\n"
530 });
531 }
532 }
533
534 fs_util::atomic_write(&path, content.as_bytes())
535 }
536
537 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
540 self.sections.iter().find(|s| s.id.provider == provider)
541 }
542
543 pub fn sections_for_provider(&self, provider: &str) -> Vec<&ProviderSection> {
545 self.sections
546 .iter()
547 .filter(|s| s.id.provider == provider)
548 .collect()
549 }
550
551 pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
553 self.sections.iter().find(|s| &s.id == id)
554 }
555
556 pub fn set_section(&mut self, section: ProviderSection) {
559 if let Some(existing) = self.sections.iter_mut().find(|s| s.id == section.id) {
560 *existing = section;
561 } else {
562 self.sections.push(section);
563 }
564 }
565
566 pub fn remove_section(&mut self, provider: &str) {
568 self.sections.retain(|s| s.id.provider != provider);
569 }
570
571 pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
573 self.sections.retain(|s| &s.id != id);
574 }
575
576 pub fn configured_providers(&self) -> &[ProviderSection] {
578 &self.sections
579 }
580
581 pub fn validate(&self) -> Result<(), String> {
587 let mut seen_ids: Vec<&ProviderConfigId> = Vec::new();
588 for s in &self.sections {
589 if let Some(label) = &s.id.label {
590 validate_label(label).map_err(|e| format!("[{}]: {}", s.id, e))?;
591 }
592 if seen_ids.iter().any(|id| **id == s.id) {
593 return Err(format!("duplicate section [{}]", s.id));
594 }
595 seen_ids.push(&s.id);
596 }
597 for s in &self.sections {
598 let bare = self
599 .sections
600 .iter()
601 .any(|o| o.id.provider == s.id.provider && o.id.label.is_none());
602 let labeled = self
603 .sections
604 .iter()
605 .any(|o| o.id.provider == s.id.provider && o.id.label.is_some());
606 if bare && labeled {
607 return Err(format!(
608 "provider '{}' has both bare and labeled sections",
609 s.id.provider
610 ));
611 }
612 }
613 let mut seen_prefixes: Vec<&str> = Vec::new();
614 for s in &self.sections {
615 if s.alias_prefix.is_empty() {
616 continue;
617 }
618 if seen_prefixes.contains(&s.alias_prefix.as_str()) {
619 return Err(format!(
620 "duplicate alias_prefix '{}' across sections",
621 s.alias_prefix
622 ));
623 }
624 seen_prefixes.push(&s.alias_prefix);
625 }
626 Ok(())
627 }
628}
629
630#[cfg(test)]
631#[path = "config_tests.rs"]
632mod tests;