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;
6use crate::providers::ProviderKind;
7
8/// Identifier for one provider-config section. Bare slug ("digitalocean") when
9/// it is the only config for that provider; provider+label ("digitalocean:work")
10/// when multiple configs coexist for the same provider.
11///
12/// The label charset is strict ([a-z0-9-], max 32) so the `:`-separator in the
13/// `# purple:provider <id>:<server_id>` SSH marker stays unambiguous even if
14/// future server IDs contain colons.
15///
16/// Fields are `pub(crate)` so external callers can't construct an invalid id
17/// by direct field mutation. Use `bare()`, `labeled()` or `FromStr`. The
18/// internal placeholder pattern in the add-flow (constructing with an empty
19/// label, then filling it via the form) lives within the crate and is
20/// validated again by `ProviderConfig::save()` before reaching disk.
21#[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    /// Typed provider kind, or None if the stored name does not match any known provider.
43    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
85/// Validate a config label. Strict charset prevents collisions with marker
86/// server_id parsing: [a-z0-9-]+, max 32 chars, no leading/trailing dash.
87pub 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/// A configured provider section from ~/.purple/providers.
107#[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    /// Optional `VAULT_ADDR` override passed to the `vault` CLI when signing
123    /// SSH certs. Empty = inherit parent env. Stored as a plain string so an
124    /// uninitialized field (via `..Default::default()`) stays innocuous.
125    pub vault_addr: String,
126}
127
128impl ProviderSection {
129    /// Convenience accessor for the bare provider name (without label).
130    pub fn provider(&self) -> &str {
131        &self.id.provider
132    }
133}
134
135/// Manual `Debug` so secret-bearing fields never leak into log lines,
136/// panic messages, or test failure output via `{:?}`. Redacts the API
137/// `token` and the `vault_addr` (which reveals internal Vault topology).
138impl 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 defaults to true (secure). A user who wants to sync
177            // against self-signed Proxmox must opt in explicitly.
178            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
190/// Default for auto_sync. Delegates to `ProviderKind::default_auto_sync`;
191/// unknown provider names default to true.
192fn default_auto_sync(provider: &str) -> bool {
193    provider
194        .parse::<ProviderKind>()
195        .ok()
196        .is_none_or(ProviderKind::default_auto_sync)
197}
198
199/// Parsed provider configuration from ~/.purple/providers.
200#[derive(Debug, Clone, Default)]
201pub struct ProviderConfig {
202    pub sections: Vec<ProviderSection>,
203    /// Override path for save(). None uses the default ~/.purple/providers.
204    /// Set to Some in tests to avoid writing to the real config.
205    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    /// Load provider config from ~/.purple/providers.
214    /// Returns empty config if file doesn't exist (normal first-use).
215    /// Prints a warning to stderr on real IO errors (permissions, etc.).
216    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    /// Parse INI-style provider config.
233    ///
234    /// Section headers are either `[provider]` (bare, single config) or
235    /// `[provider:label]` (multi-config). Mixing both forms for the same
236    /// provider is rejected (first wins, others dropped with a warn-log).
237    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                // Reject duplicates (same provider+label combo).
262                if sections.iter().any(|s| s.id == id) {
263                    log::warn!("[config] Skipping duplicate section header [{}]", id);
264                    current = None;
265                    continue;
266                }
267                // Reject mix of bare + labeled for the same provider.
268                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                            // Silently drop invalid roles so parsing stays infallible.
331                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
332                                value
333                            } else {
334                                String::new()
335                            };
336                        }
337                        "vault_addr" => {
338                            // Same silent-drop policy as vault_role: a bad
339                            // value is ignored on parse rather than crashing
340                            // the whole config load.
341                            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    /// Strip control characters (newlines, tabs, etc.) from a config value
364    /// to prevent INI format corruption from paste errors.
365    fn sanitize_value(s: &str) -> String {
366        s.chars().filter(|c| !c.is_control()).collect()
367    }
368
369    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
370    /// Respects path_override when set (used in tests).
371    pub fn save(&self) -> io::Result<()> {
372        // Reject obviously broken in-memory state before touching disk.
373        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        // Skip demo guard when path_override is set (test-only paths should
378        // always write, even when a parallel demo test has enabled the flag).
379        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(&section.id.to_string())
403            ));
404            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
405            content.push_str(&format!(
406                "alias_prefix={}\n",
407                Self::sanitize_value(&section.alias_prefix)
408            ));
409            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
410            if !section.identity_file.is_empty() {
411                content.push_str(&format!(
412                    "key={}\n",
413                    Self::sanitize_value(&section.identity_file)
414                ));
415            }
416            if !section.url.is_empty() {
417                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.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(&section.profile)
426                ));
427            }
428            if !section.regions.is_empty() {
429                content.push_str(&format!(
430                    "regions={}\n",
431                    Self::sanitize_value(&section.regions)
432                ));
433            }
434            if !section.project.is_empty() {
435                content.push_str(&format!(
436                    "project={}\n",
437                    Self::sanitize_value(&section.project)
438                ));
439            }
440            if !section.compartment.is_empty() {
441                content.push_str(&format!(
442                    "compartment={}\n",
443                    Self::sanitize_value(&section.compartment)
444                ));
445            }
446            if !section.vault_role.is_empty()
447                && crate::vault_ssh::is_valid_role(&section.vault_role)
448            {
449                content.push_str(&format!(
450                    "vault_role={}\n",
451                    Self::sanitize_value(&section.vault_role)
452                ));
453            }
454            if !section.vault_addr.is_empty()
455                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
456            {
457                content.push_str(&format!(
458                    "vault_addr={}\n",
459                    Self::sanitize_value(&section.vault_addr)
460                ));
461            }
462            if section.auto_sync != default_auto_sync(&section.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    /// Get the first section matching the given provider name.
475    /// For multi-config use, prefer `section_by_id` or `sections_for_provider`.
476    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
477        self.sections.iter().find(|s| s.id.provider == provider)
478    }
479
480    /// Get all sections for a given provider name (multi-config support).
481    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    /// Get a section by exact ProviderConfigId match.
489    pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
490        self.sections.iter().find(|s| &s.id == id)
491    }
492
493    /// Add or replace a provider section. Matches on full ProviderConfigId so
494    /// labeled configs are independent from each other and from a bare config.
495    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    /// Remove all sections matching the given provider name (any label).
504    pub fn remove_section(&mut self, provider: &str) {
505        self.sections.retain(|s| s.id.provider != provider);
506    }
507
508    /// Remove the section with the exact ProviderConfigId.
509    pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
510        self.sections.retain(|s| &s.id != id);
511    }
512
513    /// Get all configured provider sections.
514    pub fn configured_providers(&self) -> &[ProviderSection] {
515        &self.sections
516    }
517
518    /// Validate the in-memory section set:
519    /// - no duplicate ProviderConfigId
520    /// - no mix of bare + labeled for the same provider
521    /// - no duplicate alias_prefix anywhere (case-sensitive)
522    /// - all labels pass `validate_label`
523    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;