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        if crate::demo_flag::is_demo() {
176            return Ok(());
177        }
178        let path = match &self.path_override {
179            Some(p) => p.clone(),
180            None => match config_path() {
181                Some(p) => p,
182                None => {
183                    return Err(io::Error::new(
184                        io::ErrorKind::NotFound,
185                        "Could not determine home directory",
186                    ));
187                }
188            },
189        };
190
191        let mut content = String::new();
192        for (i, section) in self.sections.iter().enumerate() {
193            if i > 0 {
194                content.push('\n');
195            }
196            content.push_str(&format!("[{}]\n", Self::sanitize_value(&section.provider)));
197            content.push_str(&format!("token={}\n", Self::sanitize_value(&section.token)));
198            content.push_str(&format!(
199                "alias_prefix={}\n",
200                Self::sanitize_value(&section.alias_prefix)
201            ));
202            content.push_str(&format!("user={}\n", Self::sanitize_value(&section.user)));
203            if !section.identity_file.is_empty() {
204                content.push_str(&format!(
205                    "key={}\n",
206                    Self::sanitize_value(&section.identity_file)
207                ));
208            }
209            if !section.url.is_empty() {
210                content.push_str(&format!("url={}\n", Self::sanitize_value(&section.url)));
211            }
212            if !section.verify_tls {
213                content.push_str("verify_tls=false\n");
214            }
215            if !section.profile.is_empty() {
216                content.push_str(&format!(
217                    "profile={}\n",
218                    Self::sanitize_value(&section.profile)
219                ));
220            }
221            if !section.regions.is_empty() {
222                content.push_str(&format!(
223                    "regions={}\n",
224                    Self::sanitize_value(&section.regions)
225                ));
226            }
227            if !section.project.is_empty() {
228                content.push_str(&format!(
229                    "project={}\n",
230                    Self::sanitize_value(&section.project)
231                ));
232            }
233            if !section.compartment.is_empty() {
234                content.push_str(&format!(
235                    "compartment={}\n",
236                    Self::sanitize_value(&section.compartment)
237                ));
238            }
239            if !section.vault_role.is_empty()
240                && crate::vault_ssh::is_valid_role(&section.vault_role)
241            {
242                content.push_str(&format!(
243                    "vault_role={}\n",
244                    Self::sanitize_value(&section.vault_role)
245                ));
246            }
247            if !section.vault_addr.is_empty()
248                && crate::vault_ssh::is_valid_vault_addr(&section.vault_addr)
249            {
250                content.push_str(&format!(
251                    "vault_addr={}\n",
252                    Self::sanitize_value(&section.vault_addr)
253                ));
254            }
255            if section.auto_sync != default_auto_sync(&section.provider) {
256                content.push_str(if section.auto_sync {
257                    "auto_sync=true\n"
258                } else {
259                    "auto_sync=false\n"
260                });
261            }
262        }
263
264        fs_util::atomic_write(&path, content.as_bytes())
265    }
266
267    /// Get a configured provider section by name.
268    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
269        self.sections.iter().find(|s| s.provider == provider)
270    }
271
272    /// Add or replace a provider section.
273    pub fn set_section(&mut self, section: ProviderSection) {
274        if let Some(existing) = self
275            .sections
276            .iter_mut()
277            .find(|s| s.provider == section.provider)
278        {
279            *existing = section;
280        } else {
281            self.sections.push(section);
282        }
283    }
284
285    /// Remove a provider section.
286    pub fn remove_section(&mut self, provider: &str) {
287        self.sections.retain(|s| s.provider != provider);
288    }
289
290    /// Get all configured provider sections.
291    pub fn configured_providers(&self) -> &[ProviderSection] {
292        &self.sections
293    }
294}
295
296#[cfg(test)]
297#[path = "config_tests.rs"]
298mod tests;