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    /// One-line summary of what a save wrote, for the state-change log.
135    /// Optional fields only show up when set, so each provider's record names
136    /// the fields it actually uses. Credential values stay out of it: the
137    /// token reads as set or empty and `vault_addr` is left out entirely,
138    /// matching what the `Debug` impl redacts.
139    pub fn log_record(&self) -> String {
140        let mut parts = vec![
141            format!("prefix='{}'", self.alias_prefix),
142            format!("user='{}'", self.user),
143            // Trimmed, because every gate that reads the token trims first.
144            // A whitespace-only token logged as set would contradict the
145            // sync, which treats it as absent.
146            format!(
147                "token={}",
148                if self.token.trim().is_empty() {
149                    "empty"
150                } else {
151                    "set"
152                }
153            ),
154        ];
155        for (name, value) in [
156            ("url", &self.url),
157            ("profile", &self.profile),
158            ("regions", &self.regions),
159            ("project", &self.project),
160            ("compartment", &self.compartment),
161            ("identity_file", &self.identity_file),
162            ("vault_role", &self.vault_role),
163        ] {
164            if !value.is_empty() {
165                parts.push(format!("{}='{}'", name, value));
166            }
167        }
168        parts.push(format!("verify_tls={}", self.verify_tls));
169        parts.push(format!("auto_sync={}", self.auto_sync));
170        parts.join(" ")
171    }
172}
173
174/// Manual `Debug` so secret-bearing fields never leak into log lines,
175/// panic messages, or test failure output via `{:?}`. Redacts the API
176/// `token` and the `vault_addr` (which reveals internal Vault topology).
177impl std::fmt::Debug for ProviderSection {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("ProviderSection")
180            .field("id", &self.id)
181            .field("token", &redacted(&self.token))
182            .field("alias_prefix", &self.alias_prefix)
183            .field("user", &self.user)
184            .field("identity_file", &self.identity_file)
185            .field("url", &self.url)
186            .field("verify_tls", &self.verify_tls)
187            .field("auto_sync", &self.auto_sync)
188            .field("profile", &self.profile)
189            .field("regions", &self.regions)
190            .field("project", &self.project)
191            .field("compartment", &self.compartment)
192            .field("vault_role", &self.vault_role)
193            .field("vault_addr", &redacted(&self.vault_addr))
194            .finish()
195    }
196}
197
198fn redacted(value: &str) -> &'static str {
199    if value.is_empty() {
200        "<empty>"
201    } else {
202        "<redacted>"
203    }
204}
205
206impl Default for ProviderSection {
207    fn default() -> Self {
208        Self {
209            id: ProviderConfigId::default(),
210            token: String::new(),
211            alias_prefix: String::new(),
212            user: String::new(),
213            identity_file: String::new(),
214            url: String::new(),
215            // verify_tls defaults to true (secure). A user who wants to sync
216            // against self-signed Proxmox must opt in explicitly.
217            verify_tls: true,
218            auto_sync: false,
219            profile: String::new(),
220            regions: String::new(),
221            project: String::new(),
222            compartment: String::new(),
223            vault_role: String::new(),
224            vault_addr: String::new(),
225        }
226    }
227}
228
229/// Default for auto_sync. Delegates to `ProviderKind::default_auto_sync`;
230/// unknown provider names default to true.
231fn default_auto_sync(provider: &str) -> bool {
232    provider
233        .parse::<ProviderKind>()
234        .ok()
235        .is_none_or(ProviderKind::default_auto_sync)
236}
237
238/// Parsed provider configuration from ~/.purple/providers.
239#[derive(Debug, Clone, Default)]
240pub struct ProviderConfig {
241    pub sections: Vec<ProviderSection>,
242    /// Override path for save(). None uses the default ~/.purple/providers.
243    /// Set to Some in tests to avoid writing to the real config.
244    pub path_override: Option<PathBuf>,
245}
246
247fn config_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
248    paths.map(crate::runtime::env::Paths::providers_config)
249}
250
251impl ProviderConfig {
252    /// Load provider config from `~/.purple/providers`, resolved from the
253    /// injected `paths`. The resolved path is stored as `path_override` so
254    /// a later `save()` writes back to the same location. Returns an empty
255    /// config when the file does not exist (normal first-use) or when no
256    /// home directory is known. Logs a warning on real IO errors.
257    pub fn load(paths: Option<&crate::runtime::env::Paths>) -> Self {
258        let path = match config_path(paths) {
259            Some(p) => p,
260            None => return Self::default(),
261        };
262        let content = match std::fs::read_to_string(&path) {
263            Ok(c) => c,
264            Err(e) if e.kind() == io::ErrorKind::NotFound => {
265                return Self {
266                    path_override: Some(path),
267                    ..Self::default()
268                };
269            }
270            Err(e) => {
271                log::warn!("[config] Could not read {}: {}", path.display(), e);
272                return Self {
273                    path_override: Some(path),
274                    ..Self::default()
275                };
276            }
277        };
278        Self {
279            path_override: Some(path),
280            ..Self::parse(&content)
281        }
282    }
283
284    /// Parse INI-style provider config.
285    ///
286    /// Section headers are either `[provider]` (bare, single config) or
287    /// `[provider:label]` (multi-config). Mixing both forms for the same
288    /// provider is rejected (first wins, others dropped with a warn-log).
289    pub(crate) fn parse(content: &str) -> Self {
290        let mut sections: Vec<ProviderSection> = Vec::new();
291        let mut current: Option<ProviderSection> = None;
292
293        for line in content.lines() {
294            let trimmed = line.trim();
295            if trimmed.is_empty() || trimmed.starts_with('#') {
296                continue;
297            }
298            if trimmed.starts_with('[') && trimmed.ends_with(']') {
299                if let Some(section) = current.take() {
300                    if !sections.iter().any(|s| s.id == section.id) {
301                        sections.push(section);
302                    }
303                }
304                let raw = trimmed[1..trimmed.len() - 1].trim();
305                let id = match ProviderConfigId::from_str(raw) {
306                    Ok(id) => id,
307                    Err(e) => {
308                        log::warn!("[config] Skipping invalid section header [{}]: {}", raw, e);
309                        current = None;
310                        continue;
311                    }
312                };
313                // Reject duplicates (same provider+label combo).
314                if sections.iter().any(|s| s.id == id) {
315                    log::warn!("[config] Skipping duplicate section header [{}]", id);
316                    current = None;
317                    continue;
318                }
319                // Reject mix of bare + labeled for the same provider.
320                let has_bare = sections
321                    .iter()
322                    .any(|s| s.id.provider == id.provider && s.id.label.is_none());
323                let has_labeled = sections
324                    .iter()
325                    .any(|s| s.id.provider == id.provider && s.id.label.is_some());
326                if (id.label.is_some() && has_bare) || (id.label.is_none() && has_labeled) {
327                    log::warn!(
328                        "[config] Skipping [{}]: mixing bare and labeled sections for provider '{}' is not allowed",
329                        id,
330                        id.provider
331                    );
332                    current = None;
333                    continue;
334                }
335                let short_label = super::get_provider(&id.provider)
336                    .map(|p| p.short_label().to_string())
337                    .unwrap_or_else(|| id.provider.clone());
338                let auto_sync_default = default_auto_sync(&id.provider);
339                let alias_prefix = match &id.label {
340                    Some(l) => format!("{}-{}", short_label, l),
341                    None => short_label,
342                };
343                current = Some(ProviderSection {
344                    id,
345                    token: String::new(),
346                    alias_prefix,
347                    user: "root".to_string(),
348                    identity_file: String::new(),
349                    url: String::new(),
350                    verify_tls: true,
351                    auto_sync: auto_sync_default,
352                    profile: String::new(),
353                    regions: String::new(),
354                    project: String::new(),
355                    compartment: String::new(),
356                    vault_role: String::new(),
357                    vault_addr: String::new(),
358                });
359            } else if let Some(ref mut section) = current {
360                if let Some((key, value)) = trimmed.split_once('=') {
361                    let key = key.trim();
362                    let value = value.trim().to_string();
363                    match key {
364                        "token" => section.token = value,
365                        "alias_prefix" => section.alias_prefix = value,
366                        "user" => section.user = value,
367                        "key" => section.identity_file = value,
368                        "url" => section.url = value,
369                        "verify_tls" => {
370                            section.verify_tls =
371                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
372                        }
373                        "auto_sync" => {
374                            section.auto_sync =
375                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
376                        }
377                        "profile" => section.profile = value,
378                        "regions" => section.regions = value,
379                        "project" => section.project = value,
380                        "compartment" => section.compartment = value,
381                        "vault_role" => {
382                            // Silently drop invalid roles so parsing stays infallible.
383                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
384                                value
385                            } else {
386                                String::new()
387                            };
388                        }
389                        "vault_addr" => {
390                            // Same silent-drop policy as vault_role: a bad
391                            // value is ignored on parse rather than crashing
392                            // the whole config load.
393                            section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
394                                value
395                            } else {
396                                String::new()
397                            };
398                        }
399                        _ => {}
400                    }
401                }
402            }
403        }
404        if let Some(section) = current {
405            if !sections.iter().any(|s| s.id == section.id) {
406                sections.push(section);
407            }
408        }
409        Self {
410            sections,
411            path_override: None,
412        }
413    }
414
415    /// Strip control characters (newlines, tabs, etc.) from a config value
416    /// to prevent INI format corruption from paste errors.
417    fn sanitize_value(s: &str) -> String {
418        s.chars().filter(|c| !c.is_control()).collect()
419    }
420
421    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
422    /// Respects path_override when set (used in tests).
423    pub fn save(&self) -> io::Result<()> {
424        // Reject obviously broken in-memory state before touching disk.
425        if let Err(e) = self.validate() {
426            log::warn!("[config] Refusing to save invalid provider config: {}", e);
427            return Err(io::Error::new(io::ErrorKind::InvalidData, e));
428        }
429        // Demo mode never writes to the user's real config. In production
430        // builds that means an unconditional skip; under `#[cfg(test)]` the
431        // resolved path is always an isolated sandbox or explicit override,
432        // so tests write regardless of a parallel test toggling the global
433        // demo flag.
434        if crate::demo_flag::is_demo() {
435            #[cfg(not(test))]
436            return Ok(());
437        }
438        let Some(path) = self.path_override.clone() else {
439            return Err(io::Error::new(
440                io::ErrorKind::NotFound,
441                "Could not determine home directory",
442            ));
443        };
444
445        let mut content = String::new();
446        for (i, section) in self.sections.iter().enumerate() {
447            if i > 0 {
448                content.push('\n');
449            }
450            content.push_str(&format!(
451                "[{}]\n",
452                Self::sanitize_value(&section.id.to_string())
453            ));
454            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
455            content.push_str(&format!(
456                "alias_prefix={}\n",
457                Self::sanitize_value(&section.alias_prefix)
458            ));
459            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
460            if !section.identity_file.is_empty() {
461                content.push_str(&format!(
462                    "key={}\n",
463                    Self::sanitize_value(&section.identity_file)
464                ));
465            }
466            if !section.url.is_empty() {
467                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.url)));
468            }
469            if !section.verify_tls {
470                content.push_str("verify_tls=false\n");
471            }
472            if !section.profile.is_empty() {
473                content.push_str(&format!(
474                    "profile={}\n",
475                    Self::sanitize_value(&section.profile)
476                ));
477            }
478            if !section.regions.is_empty() {
479                content.push_str(&format!(
480                    "regions={}\n",
481                    Self::sanitize_value(&section.regions)
482                ));
483            }
484            if !section.project.is_empty() {
485                content.push_str(&format!(
486                    "project={}\n",
487                    Self::sanitize_value(&section.project)
488                ));
489            }
490            if !section.compartment.is_empty() {
491                content.push_str(&format!(
492                    "compartment={}\n",
493                    Self::sanitize_value(&section.compartment)
494                ));
495            }
496            if !section.vault_role.is_empty()
497                && crate::vault_ssh::is_valid_role(&section.vault_role)
498            {
499                content.push_str(&format!(
500                    "vault_role={}\n",
501                    Self::sanitize_value(&section.vault_role)
502                ));
503            }
504            if !section.vault_addr.is_empty()
505                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
506            {
507                content.push_str(&format!(
508                    "vault_addr={}\n",
509                    Self::sanitize_value(&section.vault_addr)
510                ));
511            }
512            if section.auto_sync != default_auto_sync(&section.id.provider) {
513                content.push_str(if section.auto_sync {
514                    "auto_sync=true\n"
515                } else {
516                    "auto_sync=false\n"
517                });
518            }
519        }
520
521        fs_util::atomic_write(&path, content.as_bytes())
522    }
523
524    /// Get the first section matching the given provider name.
525    /// For multi-config use, prefer `section_by_id` or `sections_for_provider`.
526    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
527        self.sections.iter().find(|s| s.id.provider == provider)
528    }
529
530    /// Get all sections for a given provider name (multi-config support).
531    pub fn sections_for_provider(&self, provider: &str) -> Vec<&ProviderSection> {
532        self.sections
533            .iter()
534            .filter(|s| s.id.provider == provider)
535            .collect()
536    }
537
538    /// Get a section by exact ProviderConfigId match.
539    pub fn section_by_id(&self, id: &ProviderConfigId) -> Option<&ProviderSection> {
540        self.sections.iter().find(|s| &s.id == id)
541    }
542
543    /// Add or replace a provider section. Matches on full ProviderConfigId so
544    /// labeled configs are independent from each other and from a bare config.
545    pub fn set_section(&mut self, section: ProviderSection) {
546        if let Some(existing) = self.sections.iter_mut().find(|s| s.id == section.id) {
547            *existing = section;
548        } else {
549            self.sections.push(section);
550        }
551    }
552
553    /// Remove all sections matching the given provider name (any label).
554    pub fn remove_section(&mut self, provider: &str) {
555        self.sections.retain(|s| s.id.provider != provider);
556    }
557
558    /// Remove the section with the exact ProviderConfigId.
559    pub fn remove_section_by_id(&mut self, id: &ProviderConfigId) {
560        self.sections.retain(|s| &s.id != id);
561    }
562
563    /// Get all configured provider sections.
564    pub fn configured_providers(&self) -> &[ProviderSection] {
565        &self.sections
566    }
567
568    /// Validate the in-memory section set:
569    /// - no duplicate ProviderConfigId
570    /// - no mix of bare + labeled for the same provider
571    /// - no duplicate alias_prefix anywhere (case-sensitive)
572    /// - all labels pass `validate_label`
573    pub fn validate(&self) -> Result<(), String> {
574        let mut seen_ids: Vec<&ProviderConfigId> = Vec::new();
575        for s in &self.sections {
576            if let Some(label) = &s.id.label {
577                validate_label(label).map_err(|e| format!("[{}]: {}", s.id, e))?;
578            }
579            if seen_ids.iter().any(|id| **id == s.id) {
580                return Err(format!("duplicate section [{}]", s.id));
581            }
582            seen_ids.push(&s.id);
583        }
584        for s in &self.sections {
585            let bare = self
586                .sections
587                .iter()
588                .any(|o| o.id.provider == s.id.provider && o.id.label.is_none());
589            let labeled = self
590                .sections
591                .iter()
592                .any(|o| o.id.provider == s.id.provider && o.id.label.is_some());
593            if bare && labeled {
594                return Err(format!(
595                    "provider '{}' has both bare and labeled sections",
596                    s.id.provider
597                ));
598            }
599        }
600        let mut seen_prefixes: Vec<&str> = Vec::new();
601        for s in &self.sections {
602            if s.alias_prefix.is_empty() {
603                continue;
604            }
605            if seen_prefixes.contains(&s.alias_prefix.as_str()) {
606                return Err(format!(
607                    "duplicate alias_prefix '{}' across sections",
608                    s.alias_prefix
609                ));
610            }
611            seen_prefixes.push(&s.alias_prefix);
612        }
613        Ok(())
614    }
615}
616
617#[cfg(test)]
618#[path = "config_tests.rs"]
619mod tests;