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