1use std::io;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use crate::fs_util;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct ProviderConfigId {
22 pub(crate) provider: String,
23 pub(crate) label: Option<String>,
24}
25
26impl ProviderConfigId {
27 pub fn bare(provider: impl Into<String>) -> Self {
28 Self {
29 provider: provider.into(),
30 label: None,
31 }
32 }
33
34 pub fn labeled(provider: impl Into<String>, label: impl Into<String>) -> Self {
35 Self {
36 provider: provider.into(),
37 label: Some(label.into()),
38 }
39 }
40}
41
42impl Default for ProviderConfigId {
43 fn default() -> Self {
44 Self::bare(String::new())
45 }
46}
47
48impl std::fmt::Display for ProviderConfigId {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match &self.label {
51 None => f.write_str(&self.provider),
52 Some(l) => write!(f, "{}:{}", self.provider, l),
53 }
54 }
55}
56
57impl FromStr for ProviderConfigId {
58 type Err = String;
59
60 fn from_str(s: &str) -> Result<Self, Self::Err> {
61 match s.split_once(':') {
62 Some((p, l)) => {
63 if p.is_empty() {
64 return Err("provider name is empty".to_string());
65 }
66 validate_label(l)?;
67 Ok(Self::labeled(p, l))
68 }
69 None => {
70 if s.is_empty() {
71 return Err("provider name is empty".to_string());
72 }
73 Ok(Self::bare(s))
74 }
75 }
76 }
77}
78
79pub fn validate_label(label: &str) -> Result<(), String> {
82 if label.is_empty() {
83 return Err("label is empty".to_string());
84 }
85 if label.len() > 32 {
86 return Err("label exceeds 32 characters".to_string());
87 }
88 if !label
89 .chars()
90 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
91 {
92 return Err("label contains illegal characters (only [a-z0-9-] allowed)".to_string());
93 }
94 if label.starts_with('-') || label.ends_with('-') {
95 return Err("label must not start or end with a dash".to_string());
96 }
97 Ok(())
98}
99
100#[derive(Debug, Clone)]
102pub struct ProviderSection {
103 pub id: ProviderConfigId,
104 pub token: String,
105 pub alias_prefix: String,
106 pub user: String,
107 pub identity_file: String,
108 pub url: String,
109 pub verify_tls: bool,
110 pub auto_sync: bool,
111 pub profile: String,
112 pub regions: String,
113 pub project: String,
114 pub compartment: String,
115 pub vault_role: String,
116 pub vault_addr: String,
120}
121
122impl ProviderSection {
123 pub fn provider(&self) -> &str {
125 &self.id.provider
126 }
127}
128
129impl Default for ProviderSection {
130 fn default() -> Self {
131 Self {
132 id: ProviderConfigId::default(),
133 token: String::new(),
134 alias_prefix: String::new(),
135 user: String::new(),
136 identity_file: String::new(),
137 url: String::new(),
138 verify_tls: true,
141 auto_sync: false,
142 profile: String::new(),
143 regions: String::new(),
144 project: String::new(),
145 compartment: String::new(),
146 vault_role: String::new(),
147 vault_addr: String::new(),
148 }
149 }
150}
151
152fn default_auto_sync(provider: &str) -> bool {
154 !matches!(provider, "proxmox")
155}
156
157#[derive(Debug, Clone, Default)]
159pub struct ProviderConfig {
160 pub sections: Vec<ProviderSection>,
161 pub path_override: Option<PathBuf>,
164}
165
166fn config_path() -> Option<PathBuf> {
167 dirs::home_dir().map(|h| h.join(".purple/providers"))
168}
169
170impl ProviderConfig {
171 pub fn load() -> Self {
175 let path = match config_path() {
176 Some(p) => p,
177 None => return Self::default(),
178 };
179 let content = match std::fs::read_to_string(&path) {
180 Ok(c) => c,
181 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
182 Err(e) => {
183 log::warn!("[config] Could not read {}: {}", path.display(), e);
184 return Self::default();
185 }
186 };
187 Self::parse(&content)
188 }
189
190 pub(crate) fn parse(content: &str) -> Self {
196 let mut sections: Vec<ProviderSection> = Vec::new();
197 let mut current: Option<ProviderSection> = None;
198
199 for line in content.lines() {
200 let trimmed = line.trim();
201 if trimmed.is_empty() || trimmed.starts_with('#') {
202 continue;
203 }
204 if trimmed.starts_with('[') && trimmed.ends_with(']') {
205 if let Some(section) = current.take() {
206 if !sections.iter().any(|s| s.id == section.id) {
207 sections.push(section);
208 }
209 }
210 let raw = trimmed[1..trimmed.len() - 1].trim();
211 let id = match ProviderConfigId::from_str(raw) {
212 Ok(id) => id,
213 Err(e) => {
214 log::warn!("[config] Skipping invalid section header [{}]: {}", raw, e);
215 current = None;
216 continue;
217 }
218 };
219 if sections.iter().any(|s| s.id == id) {
221 log::warn!("[config] Skipping duplicate section header [{}]", id);
222 current = None;
223 continue;
224 }
225 let has_bare = sections
227 .iter()
228 .any(|s| s.id.provider == id.provider && s.id.label.is_none());
229 let has_labeled = sections
230 .iter()
231 .any(|s| s.id.provider == id.provider && s.id.label.is_some());
232 if (id.label.is_some() && has_bare) || (id.label.is_none() && has_labeled) {
233 log::warn!(
234 "[config] Skipping [{}]: mixing bare and labeled sections for provider '{}' is not allowed",
235 id,
236 id.provider
237 );
238 current = None;
239 continue;
240 }
241 let short_label = super::get_provider(&id.provider)
242 .map(|p| p.short_label().to_string())
243 .unwrap_or_else(|| id.provider.clone());
244 let auto_sync_default = default_auto_sync(&id.provider);
245 let alias_prefix = match &id.label {
246 Some(l) => format!("{}-{}", short_label, l),
247 None => short_label,
248 };
249 current = Some(ProviderSection {
250 id,
251 token: String::new(),
252 alias_prefix,
253 user: "root".to_string(),
254 identity_file: String::new(),
255 url: String::new(),
256 verify_tls: true,
257 auto_sync: auto_sync_default,
258 profile: String::new(),
259 regions: String::new(),
260 project: String::new(),
261 compartment: String::new(),
262 vault_role: String::new(),
263 vault_addr: String::new(),
264 });
265 } else if let Some(ref mut section) = current {
266 if let Some((key, value)) = trimmed.split_once('=') {
267 let key = key.trim();
268 let value = value.trim().to_string();
269 match key {
270 "token" => section.token = value,
271 "alias_prefix" => section.alias_prefix = value,
272 "user" => section.user = value,
273 "key" => section.identity_file = value,
274 "url" => section.url = value,
275 "verify_tls" => {
276 section.verify_tls =
277 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
278 }
279 "auto_sync" => {
280 section.auto_sync =
281 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
282 }
283 "profile" => section.profile = value,
284 "regions" => section.regions = value,
285 "project" => section.project = value,
286 "compartment" => section.compartment = value,
287 "vault_role" => {
288 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
290 value
291 } else {
292 String::new()
293 };
294 }
295 "vault_addr" => {
296 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
300 value
301 } else {
302 String::new()
303 };
304 }
305 _ => {}
306 }
307 }
308 }
309 }
310 if let Some(section) = current {
311 if !sections.iter().any(|s| s.id == section.id) {
312 sections.push(section);
313 }
314 }
315 Self {
316 sections,
317 path_override: None,
318 }
319 }
320
321 fn sanitize_value(s: &str) -> String {
324 s.chars().filter(|c| !c.is_control()).collect()
325 }
326
327 pub fn save(&self) -> io::Result<()> {
330 if let Err(e) = self.validate() {
332 log::warn!("[config] Refusing to save invalid provider config: {}", e);
333 return Err(io::Error::new(io::ErrorKind::InvalidData, e));
334 }
335 if self.path_override.is_none() && crate::demo_flag::is_demo() {
338 return Ok(());
339 }
340 let path = match &self.path_override {
341 Some(p) => p.clone(),
342 None => match config_path() {
343 Some(p) => p,
344 None => {
345 return Err(io::Error::new(
346 io::ErrorKind::NotFound,
347 "Could not determine home directory",
348 ));
349 }
350 },
351 };
352
353 let mut content = String::new();
354 for (i, section) in self.sections.iter().enumerate() {
355 if i > 0 {
356 content.push('\n');
357 }
358 content.push_str(&format!(
359 "[{}]\n",
360 Self::sanitize_value(§ion.id.to_string())
361 ));
362 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
363 content.push_str(&format!(
364 "alias_prefix={}\n",
365 Self::sanitize_value(§ion.alias_prefix)
366 ));
367 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
368 if !section.identity_file.is_empty() {
369 content.push_str(&format!(
370 "key={}\n",
371 Self::sanitize_value(§ion.identity_file)
372 ));
373 }
374 if !section.url.is_empty() {
375 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
376 }
377 if !section.verify_tls {
378 content.push_str("verify_tls=false\n");
379 }
380 if !section.profile.is_empty() {
381 content.push_str(&format!(
382 "profile={}\n",
383 Self::sanitize_value(§ion.profile)
384 ));
385 }
386 if !section.regions.is_empty() {
387 content.push_str(&format!(
388 "regions={}\n",
389 Self::sanitize_value(§ion.regions)
390 ));
391 }
392 if !section.project.is_empty() {
393 content.push_str(&format!(
394 "project={}\n",
395 Self::sanitize_value(§ion.project)
396 ));
397 }
398 if !section.compartment.is_empty() {
399 content.push_str(&format!(
400 "compartment={}\n",
401 Self::sanitize_value(§ion.compartment)
402 ));
403 }
404 if !section.vault_role.is_empty()
405 && crate::vault_ssh::is_valid_role(§ion.vault_role)
406 {
407 content.push_str(&format!(
408 "vault_role={}\n",
409 Self::sanitize_value(§ion.vault_role)
410 ));
411 }
412 if !section.vault_addr.is_empty()
413 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
414 {
415 content.push_str(&format!(
416 "vault_addr={}\n",
417 Self::sanitize_value(§ion.vault_addr)
418 ));
419 }
420 if section.auto_sync != default_auto_sync(§ion.id.provider) {
421 content.push_str(if section.auto_sync {
422 "auto_sync=true\n"
423 } else {
424 "auto_sync=false\n"
425 });
426 }
427 }
428
429 fs_util::atomic_write(&path, content.as_bytes())
430 }
431
432 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
435 self.sections.iter().find(|s| s.id.provider == provider)
436 }
437
438 pub fn sections_for_provider(&self, provider: &str) -> Vec<&ProviderSection> {
440 self.sections
441 .iter()
442 .filter(|s| s.id.provider == provider)
443 .collect()
444 }
445
446 pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
448 self.sections.iter().find(|s| &s.id == id)
449 }
450
451 pub fn set_section(&mut self, section: ProviderSection) {
454 if let Some(existing) = self.sections.iter_mut().find(|s| s.id == section.id) {
455 *existing = section;
456 } else {
457 self.sections.push(section);
458 }
459 }
460
461 pub fn remove_section(&mut self, provider: &str) {
463 self.sections.retain(|s| s.id.provider != provider);
464 }
465
466 pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
468 self.sections.retain(|s| &s.id != id);
469 }
470
471 pub fn configured_providers(&self) -> &[ProviderSection] {
473 &self.sections
474 }
475
476 pub fn validate(&self) -> Result<(), String> {
482 let mut seen_ids: Vec<&ProviderConfigId> = Vec::new();
483 for s in &self.sections {
484 if let Some(label) = &s.id.label {
485 validate_label(label).map_err(|e| format!("[{}]: {}", s.id, e))?;
486 }
487 if seen_ids.iter().any(|id| **id == s.id) {
488 return Err(format!("duplicate section [{}]", s.id));
489 }
490 seen_ids.push(&s.id);
491 }
492 for s in &self.sections {
493 let bare = self
494 .sections
495 .iter()
496 .any(|o| o.id.provider == s.id.provider && o.id.label.is_none());
497 let labeled = self
498 .sections
499 .iter()
500 .any(|o| o.id.provider == s.id.provider && o.id.label.is_some());
501 if bare && labeled {
502 return Err(format!(
503 "provider '{}' has both bare and labeled sections",
504 s.id.provider
505 ));
506 }
507 }
508 let mut seen_prefixes: Vec<&str> = Vec::new();
509 for s in &self.sections {
510 if s.alias_prefix.is_empty() {
511 continue;
512 }
513 if seen_prefixes.contains(&s.alias_prefix.as_str()) {
514 return Err(format!(
515 "duplicate alias_prefix '{}' across sections",
516 s.alias_prefix
517 ));
518 }
519 seen_prefixes.push(&s.alias_prefix);
520 }
521 Ok(())
522 }
523}
524
525#[cfg(test)]
526#[path = "config_tests.rs"]
527mod tests;