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