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