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 vault_role: String,
122 pub vault_addr: String,
126}
127
128impl ProviderSection {
129 pub fn provider(&self) -> &str {
131 &self.id.provider
132 }
133
134 pub fn log_record(&self) -> String {
140 let mut parts = vec![
141 format!("prefix='{}'", self.alias_prefix),
142 format!("user='{}'", self.user),
143 format!(
147 "token={}",
148 if self.token.trim().is_empty() {
149 "empty"
150 } else {
151 "set"
152 }
153 ),
154 ];
155 for (name, value) in [
156 ("url", &self.url),
157 ("profile", &self.profile),
158 ("regions", &self.regions),
159 ("project", &self.project),
160 ("compartment", &self.compartment),
161 ("identity_file", &self.identity_file),
162 ("vault_role", &self.vault_role),
163 ] {
164 if !value.is_empty() {
165 parts.push(format!("{}='{}'", name, value));
166 }
167 }
168 parts.push(format!("verify_tls={}", self.verify_tls));
169 parts.push(format!("auto_sync={}", self.auto_sync));
170 parts.join(" ")
171 }
172}
173
174impl std::fmt::Debug for ProviderSection {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("ProviderSection")
180 .field("id", &self.id)
181 .field("token", &redacted(&self.token))
182 .field("alias_prefix", &self.alias_prefix)
183 .field("user", &self.user)
184 .field("identity_file", &self.identity_file)
185 .field("url", &self.url)
186 .field("verify_tls", &self.verify_tls)
187 .field("auto_sync", &self.auto_sync)
188 .field("profile", &self.profile)
189 .field("regions", &self.regions)
190 .field("project", &self.project)
191 .field("compartment", &self.compartment)
192 .field("vault_role", &self.vault_role)
193 .field("vault_addr", &redacted(&self.vault_addr))
194 .finish()
195 }
196}
197
198fn redacted(value: &str) -> &'static str {
199 if value.is_empty() {
200 "<empty>"
201 } else {
202 "<redacted>"
203 }
204}
205
206impl Default for ProviderSection {
207 fn default() -> Self {
208 Self {
209 id: ProviderConfigId::default(),
210 token: String::new(),
211 alias_prefix: String::new(),
212 user: String::new(),
213 identity_file: String::new(),
214 url: String::new(),
215 verify_tls: true,
218 auto_sync: false,
219 profile: String::new(),
220 regions: String::new(),
221 project: String::new(),
222 compartment: String::new(),
223 vault_role: String::new(),
224 vault_addr: String::new(),
225 }
226 }
227}
228
229fn default_auto_sync(provider: &str) -> bool {
232 provider
233 .parse::<ProviderKind>()
234 .ok()
235 .is_none_or(ProviderKind::default_auto_sync)
236}
237
238#[derive(Debug, Clone, Default)]
240pub struct ProviderConfig {
241 pub sections: Vec<ProviderSection>,
242 pub path_override: Option<PathBuf>,
245}
246
247fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
248 paths.map(crate::runtime::env::Paths::providers_config)
249}
250
251impl ProviderConfig {
252 pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
258 let path = match config_path(paths) {
259 Some(p) => p,
260 None => return Self::default(),
261 };
262 let content = match std::fs::read_to_string(&path) {
263 Ok(c) => c,
264 Err(e) if e.kind() == io::ErrorKind::NotFound => {
265 return Self {
266 path_override: Some(path),
267 ..Self::default()
268 };
269 }
270 Err(e) => {
271 log::warn!("[config] Could not read {}: {}", path.display(), e);
272 return Self {
273 path_override: Some(path),
274 ..Self::default()
275 };
276 }
277 };
278 Self {
279 path_override: Some(path),
280 ..Self::parse(&content)
281 }
282 }
283
284 pub(crate) fn parse(content: &str) -> Self {
290 let mut sections: Vec<ProviderSection> = Vec::new();
291 let mut current: Option<ProviderSection> = None;
292
293 for line in content.lines() {
294 let trimmed = line.trim();
295 if trimmed.is_empty() || trimmed.starts_with('#') {
296 continue;
297 }
298 if trimmed.starts_with('[') && trimmed.ends_with(']') {
299 if let Some(section) = current.take() {
300 if !sections.iter().any(|s| s.id == section.id) {
301 sections.push(section);
302 }
303 }
304 let raw = trimmed[1..trimmed.len() - 1].trim();
305 let id = match ProviderConfigId::from_str(raw) {
306 Ok(id) => id,
307 Err(e) => {
308 log::warn!("[config] Skipping invalid section header [{}]: {}", raw, e);
309 current = None;
310 continue;
311 }
312 };
313 if sections.iter().any(|s| s.id == id) {
315 log::warn!("[config] Skipping duplicate section header [{}]", id);
316 current = None;
317 continue;
318 }
319 let has_bare = sections
321 .iter()
322 .any(|s| s.id.provider == id.provider && s.id.label.is_none());
323 let has_labeled = sections
324 .iter()
325 .any(|s| s.id.provider == id.provider && s.id.label.is_some());
326 if (id.label.is_some() && has_bare) || (id.label.is_none() && has_labeled) {
327 log::warn!(
328 "[config] Skipping [{}]: mixing bare and labeled sections for provider '{}' is not allowed",
329 id,
330 id.provider
331 );
332 current = None;
333 continue;
334 }
335 let short_label = super::get_provider(&id.provider)
336 .map(|p| p.short_label().to_string())
337 .unwrap_or_else(|| id.provider.clone());
338 let auto_sync_default = default_auto_sync(&id.provider);
339 let alias_prefix = match &id.label {
340 Some(l) => format!("{}-{}", short_label, l),
341 None => short_label,
342 };
343 current = Some(ProviderSection {
344 id,
345 token: String::new(),
346 alias_prefix,
347 user: "root".to_string(),
348 identity_file: String::new(),
349 url: String::new(),
350 verify_tls: true,
351 auto_sync: auto_sync_default,
352 profile: String::new(),
353 regions: String::new(),
354 project: String::new(),
355 compartment: String::new(),
356 vault_role: String::new(),
357 vault_addr: String::new(),
358 });
359 } else if let Some(ref mut section) = current {
360 if let Some((key, value)) = trimmed.split_once('=') {
361 let key = key.trim();
362 let value = value.trim().to_string();
363 match key {
364 "token" => section.token = value,
365 "alias_prefix" => section.alias_prefix = value,
366 "user" => section.user = value,
367 "key" => section.identity_file = value,
368 "url" => section.url = value,
369 "verify_tls" => {
370 section.verify_tls =
371 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
372 }
373 "auto_sync" => {
374 section.auto_sync =
375 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
376 }
377 "profile" => section.profile = value,
378 "regions" => section.regions = value,
379 "project" => section.project = value,
380 "compartment" => section.compartment = value,
381 "vault_role" => {
382 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
384 value
385 } else {
386 String::new()
387 };
388 }
389 "vault_addr" => {
390 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
394 value
395 } else {
396 String::new()
397 };
398 }
399 _ => {}
400 }
401 }
402 }
403 }
404 if let Some(section) = current {
405 if !sections.iter().any(|s| s.id == section.id) {
406 sections.push(section);
407 }
408 }
409 Self {
410 sections,
411 path_override: None,
412 }
413 }
414
415 fn sanitize_value(s: &str) -> String {
418 s.chars().filter(|c| !c.is_control()).collect()
419 }
420
421 pub fn save(&self) -> io::Result<()> {
424 if let Err(e) = self.validate() {
426 log::warn!("[config] Refusing to save invalid provider config: {}", e);
427 return Err(io::Error::new(io::ErrorKind::InvalidData, e));
428 }
429 if crate::demo_flag::is_demo() {
435 #[cfg(not(test))]
436 return Ok(());
437 }
438 let Some(path) = self.path_override.clone() else {
439 return Err(io::Error::new(
440 io::ErrorKind::NotFound,
441 "Could not determine home directory",
442 ));
443 };
444
445 let mut content = String::new();
446 for (i, section) in self.sections.iter().enumerate() {
447 if i > 0 {
448 content.push('\n');
449 }
450 content.push_str(&format!(
451 "[{}]\n",
452 Self::sanitize_value(§ion.id.to_string())
453 ));
454 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
455 content.push_str(&format!(
456 "alias_prefix={}\n",
457 Self::sanitize_value(§ion.alias_prefix)
458 ));
459 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
460 if !section.identity_file.is_empty() {
461 content.push_str(&format!(
462 "key={}\n",
463 Self::sanitize_value(§ion.identity_file)
464 ));
465 }
466 if !section.url.is_empty() {
467 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
468 }
469 if !section.verify_tls {
470 content.push_str("verify_tls=false\n");
471 }
472 if !section.profile.is_empty() {
473 content.push_str(&format!(
474 "profile={}\n",
475 Self::sanitize_value(§ion.profile)
476 ));
477 }
478 if !section.regions.is_empty() {
479 content.push_str(&format!(
480 "regions={}\n",
481 Self::sanitize_value(§ion.regions)
482 ));
483 }
484 if !section.project.is_empty() {
485 content.push_str(&format!(
486 "project={}\n",
487 Self::sanitize_value(§ion.project)
488 ));
489 }
490 if !section.compartment.is_empty() {
491 content.push_str(&format!(
492 "compartment={}\n",
493 Self::sanitize_value(§ion.compartment)
494 ));
495 }
496 if !section.vault_role.is_empty()
497 && crate::vault_ssh::is_valid_role(§ion.vault_role)
498 {
499 content.push_str(&format!(
500 "vault_role={}\n",
501 Self::sanitize_value(§ion.vault_role)
502 ));
503 }
504 if !section.vault_addr.is_empty()
505 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
506 {
507 content.push_str(&format!(
508 "vault_addr={}\n",
509 Self::sanitize_value(§ion.vault_addr)
510 ));
511 }
512 if section.auto_sync != default_auto_sync(§ion.id.provider) {
513 content.push_str(if section.auto_sync {
514 "auto_sync=true\n"
515 } else {
516 "auto_sync=false\n"
517 });
518 }
519 }
520
521 fs_util::atomic_write(&path, content.as_bytes())
522 }
523
524 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
527 self.sections.iter().find(|s| s.id.provider == provider)
528 }
529
530 pub fn sections_for_provider(&self, provider: &str) -> Vec<&ProviderSection> {
532 self.sections
533 .iter()
534 .filter(|s| s.id.provider == provider)
535 .collect()
536 }
537
538 pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
540 self.sections.iter().find(|s| &s.id == id)
541 }
542
543 pub fn set_section(&mut self, section: ProviderSection) {
546 if let Some(existing) = self.sections.iter_mut().find(|s| s.id == section.id) {
547 *existing = section;
548 } else {
549 self.sections.push(section);
550 }
551 }
552
553 pub fn remove_section(&mut self, provider: &str) {
555 self.sections.retain(|s| s.id.provider != provider);
556 }
557
558 pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
560 self.sections.retain(|s| &s.id != id);
561 }
562
563 pub fn configured_providers(&self) -> &[ProviderSection] {
565 &self.sections
566 }
567
568 pub fn validate(&self) -> Result<(), String> {
574 let mut seen_ids: Vec<&ProviderConfigId> = Vec::new();
575 for s in &self.sections {
576 if let Some(label) = &s.id.label {
577 validate_label(label).map_err(|e| format!("[{}]: {}", s.id, e))?;
578 }
579 if seen_ids.iter().any(|id| **id == s.id) {
580 return Err(format!("duplicate section [{}]", s.id));
581 }
582 seen_ids.push(&s.id);
583 }
584 for s in &self.sections {
585 let bare = self
586 .sections
587 .iter()
588 .any(|o| o.id.provider == s.id.provider && o.id.label.is_none());
589 let labeled = self
590 .sections
591 .iter()
592 .any(|o| o.id.provider == s.id.provider && o.id.label.is_some());
593 if bare && labeled {
594 return Err(format!(
595 "provider '{}' has both bare and labeled sections",
596 s.id.provider
597 ));
598 }
599 }
600 let mut seen_prefixes: Vec<&str> = Vec::new();
601 for s in &self.sections {
602 if s.alias_prefix.is_empty() {
603 continue;
604 }
605 if seen_prefixes.contains(&s.alias_prefix.as_str()) {
606 return Err(format!(
607 "duplicate alias_prefix '{}' across sections",
608 s.alias_prefix
609 ));
610 }
611 seen_prefixes.push(&s.alias_prefix);
612 }
613 Ok(())
614 }
615}
616
617#[cfg(test)]
618#[path = "config_tests.rs"]
619mod tests;