Skip to main content

purple_ssh/providers/
config.rs

1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6/// A configured provider section from ~/.purple/providers.
7#[derive(Debug, Clone)]
8pub struct ProviderSection {
9    pub provider: String,
10    pub token: String,
11    pub alias_prefix: String,
12    pub user: String,
13    pub identity_file: String,
14    pub url: String,
15    pub verify_tls: bool,
16    pub auto_sync: bool,
17    pub profile: String,
18    pub regions: String,
19    pub project: String,
20    pub compartment: String,
21    pub vault_role: String,
22    /// Optional `VAULT_ADDR` override passed to the `vault` CLI when signing
23    /// SSH certs. Empty = inherit parent env. Stored as a plain string so an
24    /// uninitialized field (via `..Default::default()`) stays innocuous.
25    pub vault_addr: String,
26}
27
28/// Default for auto_sync: false for proxmox (N+1 API calls), true for all others.
29fn default_auto_sync(provider: &str) -> bool {
30    !matches!(provider, "proxmox")
31}
32
33/// Parsed provider configuration from ~/.purple/providers.
34#[derive(Debug, Clone, Default)]
35pub struct ProviderConfig {
36    pub sections: Vec<ProviderSection>,
37    /// Override path for save(). None uses the default ~/.purple/providers.
38    /// Set to Some in tests to avoid writing to the real config.
39    pub path_override: Option<PathBuf>,
40}
41
42fn config_path() -> Option<PathBuf> {
43    dirs::home_dir().map(|h| h.join(".purple/providers"))
44}
45
46impl ProviderConfig {
47    /// Load provider config from ~/.purple/providers.
48    /// Returns empty config if file doesn't exist (normal first-use).
49    /// Prints a warning to stderr on real IO errors (permissions, etc.).
50    pub fn load() -> Self {
51        let path = match config_path() {
52            Some(p) => p,
53            None => return Self::default(),
54        };
55        let content = match std::fs::read_to_string(&path) {
56            Ok(c) => c,
57            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
58            Err(e) => {
59                log::warn!("[config] Could not read {}: {}", path.display(), e);
60                return Self::default();
61            }
62        };
63        Self::parse(&content)
64    }
65
66    /// Parse INI-style provider config.
67    pub(crate) fn parse(content: &str) -> Self {
68        let mut sections = Vec::new();
69        let mut current: Option<ProviderSection> = None;
70
71        for line in content.lines() {
72            let trimmed = line.trim();
73            if trimmed.is_empty() || trimmed.starts_with('#') {
74                continue;
75            }
76            if trimmed.starts_with('[') && trimmed.ends_with(']') {
77                if let Some(section) = current.take() {
78                    if !sections
79                        .iter()
80                        .any(|s: &ProviderSection| s.provider == section.provider)
81                    {
82                        sections.push(section);
83                    }
84                }
85                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
86                if sections.iter().any(|s| s.provider == name) {
87                    current = None;
88                    continue;
89                }
90                let short_label = super::get_provider(&name)
91                    .map(|p| p.short_label().to_string())
92                    .unwrap_or_else(|| name.clone());
93                let auto_sync_default = default_auto_sync(&name);
94                current = Some(ProviderSection {
95                    provider: name,
96                    token: String::new(),
97                    alias_prefix: short_label,
98                    user: "root".to_string(),
99                    identity_file: String::new(),
100                    url: String::new(),
101                    verify_tls: true,
102                    auto_sync: auto_sync_default,
103                    profile: String::new(),
104                    regions: String::new(),
105                    project: String::new(),
106                    compartment: String::new(),
107                    vault_role: String::new(),
108                    vault_addr: String::new(),
109                });
110            } else if let Some(ref mut section) = current {
111                if let Some((key, value)) = trimmed.split_once('=') {
112                    let key = key.trim();
113                    let value = value.trim().to_string();
114                    match key {
115                        "token" => section.token = value,
116                        "alias_prefix" => section.alias_prefix = value,
117                        "user" => section.user = value,
118                        "key" => section.identity_file = value,
119                        "url" => section.url = value,
120                        "verify_tls" => {
121                            section.verify_tls =
122                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
123                        }
124                        "auto_sync" => {
125                            section.auto_sync =
126                                !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
127                        }
128                        "profile" => section.profile = value,
129                        "regions" => section.regions = value,
130                        "project" => section.project = value,
131                        "compartment" => section.compartment = value,
132                        "vault_role" => {
133                            // Silently drop invalid roles so parsing stays infallible.
134                            section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
135                                value
136                            } else {
137                                String::new()
138                            };
139                        }
140                        "vault_addr" => {
141                            // Same silent-drop policy as vault_role: a bad
142                            // value is ignored on parse rather than crashing
143                            // the whole config load.
144                            section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
145                                value
146                            } else {
147                                String::new()
148                            };
149                        }
150                        _ => {}
151                    }
152                }
153            }
154        }
155        if let Some(section) = current {
156            if !sections.iter().any(|s| s.provider == section.provider) {
157                sections.push(section);
158            }
159        }
160        Self {
161            sections,
162            path_override: None,
163        }
164    }
165
166    /// Strip control characters (newlines, tabs, etc.) from a config value
167    /// to prevent INI format corruption from paste errors.
168    fn sanitize_value(s: &str) -> String {
169        s.chars().filter(|c| !c.is_control()).collect()
170    }
171
172    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
173    /// Respects path_override when set (used in tests).
174    pub fn save(&self) -> io::Result<()> {
175        // Skip demo guard when path_override is set (test-only paths should
176        // always write, even when a parallel demo test has enabled the flag).
177        if self.path_override.is_none() && crate::demo_flag::is_demo() {
178            return Ok(());
179        }
180        let path = match &self.path_override {
181            Some(p) => p.clone(),
182            None => match config_path() {
183                Some(p) => p,
184                None => {
185                    return Err(io::Error::new(
186                        io::ErrorKind::NotFound,
187                        "Could not determine home directory",
188                    ));
189                }
190            },
191        };
192
193        let mut content = String::new();
194        for (i, section) in self.sections.iter().enumerate() {
195            if i > 0 {
196                content.push('\n');
197            }
198            content.push_str(&format!("[{}]\n", Self::sanitize_value(&section.provider)));
199            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
200            content.push_str(&format!(
201                "alias_prefix={}\n",
202                Self::sanitize_value(&section.alias_prefix)
203            ));
204            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
205            if !section.identity_file.is_empty() {
206                content.push_str(&format!(
207                    "key={}\n",
208                    Self::sanitize_value(&section.identity_file)
209                ));
210            }
211            if !section.url.is_empty() {
212                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.url)));
213            }
214            if !section.verify_tls {
215                content.push_str("verify_tls=false\n");
216            }
217            if !section.profile.is_empty() {
218                content.push_str(&format!(
219                    "profile={}\n",
220                    Self::sanitize_value(&section.profile)
221                ));
222            }
223            if !section.regions.is_empty() {
224                content.push_str(&format!(
225                    "regions={}\n",
226                    Self::sanitize_value(&section.regions)
227                ));
228            }
229            if !section.project.is_empty() {
230                content.push_str(&format!(
231                    "project={}\n",
232                    Self::sanitize_value(&section.project)
233                ));
234            }
235            if !section.compartment.is_empty() {
236                content.push_str(&format!(
237                    "compartment={}\n",
238                    Self::sanitize_value(&section.compartment)
239                ));
240            }
241            if !section.vault_role.is_empty()
242                && crate::vault_ssh::is_valid_role(&section.vault_role)
243            {
244                content.push_str(&format!(
245                    "vault_role={}\n",
246                    Self::sanitize_value(&section.vault_role)
247                ));
248            }
249            if !section.vault_addr.is_empty()
250                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
251            {
252                content.push_str(&format!(
253                    "vault_addr={}\n",
254                    Self::sanitize_value(&section.vault_addr)
255                ));
256            }
257            if section.auto_sync != default_auto_sync(&section.provider) {
258                content.push_str(if section.auto_sync {
259                    "auto_sync=true\n"
260                } else {
261                    "auto_sync=false\n"
262                });
263            }
264        }
265
266        fs_util::atomic_write(&path, content.as_bytes())
267    }
268
269    /// Get a configured provider section by name.
270    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
271        self.sections.iter().find(|s| s.provider == provider)
272    }
273
274    /// Add or replace a provider section.
275    pub fn set_section(&mut self, section: ProviderSection) {
276        if let Some(existing) = self
277            .sections
278            .iter_mut()
279            .find(|s| s.provider == section.provider)
280        {
281            *existing = section;
282        } else {
283            self.sections.push(section);
284        }
285    }
286
287    /// Remove a provider section.
288    pub fn remove_section(&mut self, provider: &str) {
289        self.sections.retain(|s| s.provider != provider);
290    }
291
292    /// Get all configured provider sections.
293    pub fn configured_providers(&self) -> &[ProviderSection] {
294        &self.sections
295    }
296}
297
298#[cfg(test)]
299#[path = "config_tests.rs"]
300mod tests;