Skip to main content

purple_ssh/providers/
config.rs

1use std::io;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use crate::fs_util;
6
7/// Identifier for one provider-config section. Bare slug ("digitalocean") when
8/// it is the only config for that provider; provider+label ("digitalocean:work")
9/// when multiple configs coexist for the same provider.
10///
11/// The label charset is strict ([a-z0-9-], max 32) so the `:`-separator in the
12/// `# purple:provider <id>:<server_id>` SSH marker stays unambiguous even if
13/// future server IDs contain colons.
14///
15/// Fields are `pub(crate)` so external callers can't construct an invalid id
16/// by direct field mutation. Use `bare()`, `labeled()` or `FromStr`. The
17/// internal placeholder pattern in the add-flow (constructing with an empty
18/// label, then filling it via the form) lives within the crate and is
19/// validated again by `ProviderConfig::save()` before reaching disk.
20#[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
79/// Validate a config label. Strict charset prevents collisions with marker
80/// server_id parsing: [a-z0-9-]+, max 32 chars, no leading/trailing dash.
81pub 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/// A configured provider section from ~/.purple/providers.
101#[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    /// Optional `VAULT_ADDR` override passed to the `vault` CLI when signing
117    /// SSH certs. Empty = inherit parent env. Stored as a plain string so an
118    /// uninitialized field (via `..Default::default()`) stays innocuous.
119    pub vault_addr: String,
120}
121
122impl ProviderSection {
123    /// Convenience accessor for the bare provider name (without label).
124    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 defaults to true (secure). A user who wants to sync
139            // against self-signed Proxmox must opt in explicitly.
140            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
152/// Default for auto_sync: false for proxmox (N+1 API calls), true for all others.
153fn default_auto_sync(provider: &str) -> bool {
154    !matches!(provider, "proxmox")
155}
156
157/// Parsed provider configuration from ~/.purple/providers.
158#[derive(Debug, Clone, Default)]
159pub struct ProviderConfig {
160    pub sections: Vec<ProviderSection>,
161    /// Override path for save(). None uses the default ~/.purple/providers.
162    /// Set to Some in tests to avoid writing to the real config.
163    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    /// Load provider config from ~/.purple/providers.
172    /// Returns empty config if file doesn't exist (normal first-use).
173    /// Prints a warning to stderr on real IO errors (permissions, etc.).
174    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    /// Parse INI-style provider config.
191    ///
192    /// Section headers are either `[provider]` (bare, single config) or
193    /// `[provider:label]` (multi-config). Mixing both forms for the same
194    /// provider is rejected (first wins, others dropped with a warn-log).
195    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                // Reject duplicates (same provider+label combo).
220                if sections.iter().any(|s| s.id == id) {
221                    log::warn!("[config] Skipping duplicate section header [{}]", id);
222                    current = None;
223                    continue;
224                }
225                // Reject mix of bare + labeled for the same provider.
226                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                            // Silently drop invalid roles so parsing stays infallible.
289                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
290                                value
291                            } else {
292                                String::new()
293                            };
294                        }
295                        "vault_addr" => {
296                            // Same silent-drop policy as vault_role: a bad
297                            // value is ignored on parse rather than crashing
298                            // the whole config load.
299                            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    /// Strip control characters (newlines, tabs, etc.) from a config value
322    /// to prevent INI format corruption from paste errors.
323    fn sanitize_value(s: &str) -> String {
324        s.chars().filter(|c| !c.is_control()).collect()
325    }
326
327    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
328    /// Respects path_override when set (used in tests).
329    pub fn save(&self) -> io::Result<()> {
330        // Reject obviously broken in-memory state before touching disk.
331        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        // Skip demo guard when path_override is set (test-only paths should
336        // always write, even when a parallel demo test has enabled the flag).
337        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(&section.id.to_string())
361            ));
362            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
363            content.push_str(&format!(
364                "alias_prefix={}\n",
365                Self::sanitize_value(&section.alias_prefix)
366            ));
367            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
368            if !section.identity_file.is_empty() {
369                content.push_str(&format!(
370                    "key={}\n",
371                    Self::sanitize_value(&section.identity_file)
372                ));
373            }
374            if !section.url.is_empty() {
375                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.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(&section.profile)
384                ));
385            }
386            if !section.regions.is_empty() {
387                content.push_str(&format!(
388                    "regions={}\n",
389                    Self::sanitize_value(&section.regions)
390                ));
391            }
392            if !section.project.is_empty() {
393                content.push_str(&format!(
394                    "project={}\n",
395                    Self::sanitize_value(&section.project)
396                ));
397            }
398            if !section.compartment.is_empty() {
399                content.push_str(&format!(
400                    "compartment={}\n",
401                    Self::sanitize_value(&section.compartment)
402                ));
403            }
404            if !section.vault_role.is_empty()
405                && crate::vault_ssh::is_valid_role(&section.vault_role)
406            {
407                content.push_str(&format!(
408                    "vault_role={}\n",
409                    Self::sanitize_value(&section.vault_role)
410                ));
411            }
412            if !section.vault_addr.is_empty()
413                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
414            {
415                content.push_str(&format!(
416                    "vault_addr={}\n",
417                    Self::sanitize_value(&section.vault_addr)
418                ));
419            }
420            if section.auto_sync != default_auto_sync(&section.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    /// Get the first section matching the given provider name.
433    /// For multi-config use, prefer `section_by_id` or `sections_for_provider`.
434    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
435        self.sections.iter().find(|s| s.id.provider == provider)
436    }
437
438    /// Get all sections for a given provider name (multi-config support).
439    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    /// Get a section by exact ProviderConfigId match.
447    pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
448        self.sections.iter().find(|s| &s.id == id)
449    }
450
451    /// Add or replace a provider section. Matches on full ProviderConfigId so
452    /// labeled configs are independent from each other and from a bare config.
453    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    /// Remove all sections matching the given provider name (any label).
462    pub fn remove_section(&mut self, provider: &str) {
463        self.sections.retain(|s| s.id.provider != provider);
464    }
465
466    /// Remove the section with the exact ProviderConfigId.
467    pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
468        self.sections.retain(|s| &s.id != id);
469    }
470
471    /// Get all configured provider sections.
472    pub fn configured_providers(&self) -> &[ProviderSection] {
473        &self.sections
474    }
475
476    /// Validate the in-memory section set:
477    /// - no duplicate ProviderConfigId
478    /// - no mix of bare + labeled for the same provider
479    /// - no duplicate alias_prefix anywhere (case-sensitive)
480    /// - all labels pass `validate_label`
481    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;