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