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() -> Option<PathBuf> {
209 dirs::home_dir().map(|h| h.join(".purple/providers"))
210}
211
212impl ProviderConfig {
213 pub fn load() -> Self {
217 let path = match config_path() {
218 Some(p) => p,
219 None => return Self::default(),
220 };
221 let content = match std::fs::read_to_string(&path) {
222 Ok(c) => c,
223 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
224 Err(e) => {
225 log::warn!("[config] Could not read {}: {}", path.display(), e);
226 return Self::default();
227 }
228 };
229 Self::parse(&content)
230 }
231
232 pub(crate) fn parse(content: &str) -> Self {
238 let mut sections: Vec<ProviderSection> = Vec::new();
239 let mut current: Option<ProviderSection> = None;
240
241 for line in content.lines() {
242 let trimmed = line.trim();
243 if trimmed.is_empty() || trimmed.starts_with('#') {
244 continue;
245 }
246 if trimmed.starts_with('[') && trimmed.ends_with(']') {
247 if let Some(section) = current.take() {
248 if !sections.iter().any(|s| s.id == section.id) {
249 sections.push(section);
250 }
251 }
252 let raw = trimmed[1..trimmed.len() - 1].trim();
253 let id = match ProviderConfigId::from_str(raw) {
254 Ok(id) => id,
255 Err(e) => {
256 log::warn!("[config] Skipping invalid section header [{}]: {}", raw, e);
257 current = None;
258 continue;
259 }
260 };
261 if sections.iter().any(|s| s.id == id) {
263 log::warn!("[config] Skipping duplicate section header [{}]", id);
264 current = None;
265 continue;
266 }
267 let has_bare = sections
269 .iter()
270 .any(|s| s.id.provider == id.provider && s.id.label.is_none());
271 let has_labeled = sections
272 .iter()
273 .any(|s| s.id.provider == id.provider && s.id.label.is_some());
274 if (id.label.is_some() && has_bare) || (id.label.is_none() && has_labeled) {
275 log::warn!(
276 "[config] Skipping [{}]: mixing bare and labeled sections for provider '{}' is not allowed",
277 id,
278 id.provider
279 );
280 current = None;
281 continue;
282 }
283 let short_label = super::get_provider(&id.provider)
284 .map(|p| p.short_label().to_string())
285 .unwrap_or_else(|| id.provider.clone());
286 let auto_sync_default = default_auto_sync(&id.provider);
287 let alias_prefix = match &id.label {
288 Some(l) => format!("{}-{}", short_label, l),
289 None => short_label,
290 };
291 current = Some(ProviderSection {
292 id,
293 token: String::new(),
294 alias_prefix,
295 user: "root".to_string(),
296 identity_file: String::new(),
297 url: String::new(),
298 verify_tls: true,
299 auto_sync: auto_sync_default,
300 profile: String::new(),
301 regions: String::new(),
302 project: String::new(),
303 compartment: String::new(),
304 vault_role: String::new(),
305 vault_addr: String::new(),
306 });
307 } else if let Some(ref mut section) = current {
308 if let Some((key, value)) = trimmed.split_once('=') {
309 let key = key.trim();
310 let value = value.trim().to_string();
311 match key {
312 "token" => section.token = value,
313 "alias_prefix" => section.alias_prefix = value,
314 "user" => section.user = value,
315 "key" => section.identity_file = value,
316 "url" => section.url = value,
317 "verify_tls" => {
318 section.verify_tls =
319 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
320 }
321 "auto_sync" => {
322 section.auto_sync =
323 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
324 }
325 "profile" => section.profile = value,
326 "regions" => section.regions = value,
327 "project" => section.project = value,
328 "compartment" => section.compartment = value,
329 "vault_role" => {
330 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
332 value
333 } else {
334 String::new()
335 };
336 }
337 "vault_addr" => {
338 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
342 value
343 } else {
344 String::new()
345 };
346 }
347 _ => {}
348 }
349 }
350 }
351 }
352 if let Some(section) = current {
353 if !sections.iter().any(|s| s.id == section.id) {
354 sections.push(section);
355 }
356 }
357 Self {
358 sections,
359 path_override: None,
360 }
361 }
362
363 fn sanitize_value(s: &str) -> String {
366 s.chars().filter(|c| !c.is_control()).collect()
367 }
368
369 pub fn save(&self) -> io::Result<()> {
372 if let Err(e) = self.validate() {
374 log::warn!("[config] Refusing to save invalid provider config: {}", e);
375 return Err(io::Error::new(io::ErrorKind::InvalidData, e));
376 }
377 if self.path_override.is_none() && crate::demo_flag::is_demo() {
380 return Ok(());
381 }
382 let path = match &self.path_override {
383 Some(p) => p.clone(),
384 None => match config_path() {
385 Some(p) => p,
386 None => {
387 return Err(io::Error::new(
388 io::ErrorKind::NotFound,
389 "Could not determine home directory",
390 ));
391 }
392 },
393 };
394
395 let mut content = String::new();
396 for (i, section) in self.sections.iter().enumerate() {
397 if i > 0 {
398 content.push('\n');
399 }
400 content.push_str(&format!(
401 "[{}]\n",
402 Self::sanitize_value(§ion.id.to_string())
403 ));
404 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
405 content.push_str(&format!(
406 "alias_prefix={}\n",
407 Self::sanitize_value(§ion.alias_prefix)
408 ));
409 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
410 if !section.identity_file.is_empty() {
411 content.push_str(&format!(
412 "key={}\n",
413 Self::sanitize_value(§ion.identity_file)
414 ));
415 }
416 if !section.url.is_empty() {
417 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
418 }
419 if !section.verify_tls {
420 content.push_str("verify_tls=false\n");
421 }
422 if !section.profile.is_empty() {
423 content.push_str(&format!(
424 "profile={}\n",
425 Self::sanitize_value(§ion.profile)
426 ));
427 }
428 if !section.regions.is_empty() {
429 content.push_str(&format!(
430 "regions={}\n",
431 Self::sanitize_value(§ion.regions)
432 ));
433 }
434 if !section.project.is_empty() {
435 content.push_str(&format!(
436 "project={}\n",
437 Self::sanitize_value(§ion.project)
438 ));
439 }
440 if !section.compartment.is_empty() {
441 content.push_str(&format!(
442 "compartment={}\n",
443 Self::sanitize_value(§ion.compartment)
444 ));
445 }
446 if !section.vault_role.is_empty()
447 && crate::vault_ssh::is_valid_role(§ion.vault_role)
448 {
449 content.push_str(&format!(
450 "vault_role={}\n",
451 Self::sanitize_value(§ion.vault_role)
452 ));
453 }
454 if !section.vault_addr.is_empty()
455 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
456 {
457 content.push_str(&format!(
458 "vault_addr={}\n",
459 Self::sanitize_value(§ion.vault_addr)
460 ));
461 }
462 if section.auto_sync != default_auto_sync(§ion.id.provider) {
463 content.push_str(if section.auto_sync {
464 "auto_sync=true\n"
465 } else {
466 "auto_sync=false\n"
467 });
468 }
469 }
470
471 fs_util::atomic_write(&path, content.as_bytes())
472 }
473
474 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
477 self.sections.iter().find(|s| s.id.provider == provider)
478 }
479
480 pub fn sections_for_provider(&self, provider: &str) -> Vec<&ProviderSection> {
482 self.sections
483 .iter()
484 .filter(|s| s.id.provider == provider)
485 .collect()
486 }
487
488 pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
490 self.sections.iter().find(|s| &s.id == id)
491 }
492
493 pub fn set_section(&mut self, section: ProviderSection) {
496 if let Some(existing) = self.sections.iter_mut().find(|s| s.id == section.id) {
497 *existing = section;
498 } else {
499 self.sections.push(section);
500 }
501 }
502
503 pub fn remove_section(&mut self, provider: &str) {
505 self.sections.retain(|s| s.id.provider != provider);
506 }
507
508 pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
510 self.sections.retain(|s| &s.id != id);
511 }
512
513 pub fn configured_providers(&self) -> &[ProviderSection] {
515 &self.sections
516 }
517
518 pub fn validate(&self) -> Result<(), String> {
524 let mut seen_ids: Vec<&ProviderConfigId> = Vec::new();
525 for s in &self.sections {
526 if let Some(label) = &s.id.label {
527 validate_label(label).map_err(|e| format!("[{}]: {}", s.id, e))?;
528 }
529 if seen_ids.iter().any(|id| **id == s.id) {
530 return Err(format!("duplicate section [{}]", s.id));
531 }
532 seen_ids.push(&s.id);
533 }
534 for s in &self.sections {
535 let bare = self
536 .sections
537 .iter()
538 .any(|o| o.id.provider == s.id.provider && o.id.label.is_none());
539 let labeled = self
540 .sections
541 .iter()
542 .any(|o| o.id.provider == s.id.provider && o.id.label.is_some());
543 if bare && labeled {
544 return Err(format!(
545 "provider '{}' has both bare and labeled sections",
546 s.id.provider
547 ));
548 }
549 }
550 let mut seen_prefixes: Vec<&str> = Vec::new();
551 for s in &self.sections {
552 if s.alias_prefix.is_empty() {
553 continue;
554 }
555 if seen_prefixes.contains(&s.alias_prefix.as_str()) {
556 return Err(format!(
557 "duplicate alias_prefix '{}' across sections",
558 s.alias_prefix
559 ));
560 }
561 seen_prefixes.push(&s.alias_prefix);
562 }
563 Ok(())
564 }
565}
566
567#[cfg(test)]
568#[path = "config_tests.rs"]
569mod tests;