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(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
209    paths.map(crate::runtime::env::Paths::providers_config)
210}
211
212impl ProviderConfig {
213    /// Load provider config from `~/.purple/providers`, resolved from the
214    /// injected `paths`. The resolved path is stored as `path_override` so
215    /// a later `save()` writes back to the same location. Returns an empty
216    /// config when the file does not exist (normal first-use) or when no
217    /// home directory is known. Logs a warning on real IO errors.
218    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    /// Parse INI-style provider config.
246    ///
247    /// Section headers are either `[provider]` (bare, single config) or
248    /// `[provider:label]` (multi-config). Mixing both forms for the same
249    /// provider is rejected (first wins, others dropped with a warn-log).
250    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                // Reject duplicates (same provider+label combo).
275                if sections.iter().any(|s| s.id == id) {
276                    log::warn!("[config] Skipping duplicate section header [{}]", id);
277                    current = None;
278                    continue;
279                }
280                // Reject mix of bare + labeled for the same provider.
281                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                            // Silently drop invalid roles so parsing stays infallible.
344                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
345                                value
346                            } else {
347                                String::new()
348                            };
349                        }
350                        "vault_addr" => {
351                            // Same silent-drop policy as vault_role: a bad
352                            // value is ignored on parse rather than crashing
353                            // the whole config load.
354                            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    /// Strip control characters (newlines, tabs, etc.) from a config value
377    /// to prevent INI format corruption from paste errors.
378    fn sanitize_value(s: &str) -> String {
379        s.chars().filter(|c| !c.is_control()).collect()
380    }
381
382    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
383    /// Respects path_override when set (used in tests).
384    pub fn save(&self) -> io::Result<()> {
385        // Reject obviously broken in-memory state before touching disk.
386        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        // Demo mode never writes to the user's real config. In production
391        // builds that means an unconditional skip; under `#[cfg(test)]` the
392        // resolved path is always an isolated sandbox or explicit override,
393        // so tests write regardless of a parallel test toggling the global
394        // demo flag.
395        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(&section.id.to_string())
414            ));
415            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
416            content.push_str(&format!(
417                "alias_prefix={}\n",
418                Self::sanitize_value(&section.alias_prefix)
419            ));
420            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
421            if !section.identity_file.is_empty() {
422                content.push_str(&format!(
423                    "key={}\n",
424                    Self::sanitize_value(&section.identity_file)
425                ));
426            }
427            if !section.url.is_empty() {
428                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.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(&section.profile)
437                ));
438            }
439            if !section.regions.is_empty() {
440                content.push_str(&format!(
441                    "regions={}\n",
442                    Self::sanitize_value(&section.regions)
443                ));
444            }
445            if !section.project.is_empty() {
446                content.push_str(&format!(
447                    "project={}\n",
448                    Self::sanitize_value(&section.project)
449                ));
450            }
451            if !section.compartment.is_empty() {
452                content.push_str(&format!(
453                    "compartment={}\n",
454                    Self::sanitize_value(&section.compartment)
455                ));
456            }
457            if !section.vault_role.is_empty()
458                && crate::vault_ssh::is_valid_role(&section.vault_role)
459            {
460                content.push_str(&format!(
461                    "vault_role={}\n",
462                    Self::sanitize_value(&section.vault_role)
463                ));
464            }
465            if !section.vault_addr.is_empty()
466                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
467            {
468                content.push_str(&format!(
469                    "vault_addr={}\n",
470                    Self::sanitize_value(&section.vault_addr)
471                ));
472            }
473            if section.auto_sync != default_auto_sync(&section.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    /// Get the first section matching the given provider name.
486    /// For multi-config use, prefer `section_by_id` or `sections_for_provider`.
487    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
488        self.sections.iter().find(|s| s.id.provider == provider)
489    }
490
491    /// Get all sections for a given provider name (multi-config support).
492    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    /// Get a section by exact ProviderConfigId match.
500    pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
501        self.sections.iter().find(|s| &s.id == id)
502    }
503
504    /// Add or replace a provider section. Matches on full ProviderConfigId so
505    /// labeled configs are independent from each other and from a bare config.
506    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    /// Remove all sections matching the given provider name (any label).
515    pub fn remove_section(&mut self, provider: &str) {
516        self.sections.retain(|s| s.id.provider != provider);
517    }
518
519    /// Remove the section with the exact ProviderConfigId.
520    pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
521        self.sections.retain(|s| &s.id != id);
522    }
523
524    /// Get all configured provider sections.
525    pub fn configured_providers(&self) -> &[ProviderSection] {
526        &self.sections
527    }
528
529    /// Validate the in-memory section set:
530    /// - no duplicate ProviderConfigId
531    /// - no mix of bare + labeled for the same provider
532    /// - no duplicate alias_prefix anywhere (case-sensitive)
533    /// - all labels pass `validate_label`
534    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;