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