purple_ssh/providers/
config.rs1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6#[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 pub vault_addr: String,
26}
27
28fn default_auto_sync(provider: &str) -> bool {
30 !matches!(provider, "proxmox")
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct ProviderConfig {
36 pub sections: Vec<ProviderSection>,
37 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 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 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 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
135 value
136 } else {
137 String::new()
138 };
139 }
140 "vault_addr" => {
141 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 fn sanitize_value(s: &str) -> String {
169 s.chars().filter(|c| !c.is_control()).collect()
170 }
171
172 pub fn save(&self) -> io::Result<()> {
175 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(§ion.provider)));
199 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
200 content.push_str(&format!(
201 "alias_prefix={}\n",
202 Self::sanitize_value(§ion.alias_prefix)
203 ));
204 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
205 if !section.identity_file.is_empty() {
206 content.push_str(&format!(
207 "key={}\n",
208 Self::sanitize_value(§ion.identity_file)
209 ));
210 }
211 if !section.url.is_empty() {
212 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.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(§ion.profile)
221 ));
222 }
223 if !section.regions.is_empty() {
224 content.push_str(&format!(
225 "regions={}\n",
226 Self::sanitize_value(§ion.regions)
227 ));
228 }
229 if !section.project.is_empty() {
230 content.push_str(&format!(
231 "project={}\n",
232 Self::sanitize_value(§ion.project)
233 ));
234 }
235 if !section.compartment.is_empty() {
236 content.push_str(&format!(
237 "compartment={}\n",
238 Self::sanitize_value(§ion.compartment)
239 ));
240 }
241 if !section.vault_role.is_empty()
242 && crate::vault_ssh::is_valid_role(§ion.vault_role)
243 {
244 content.push_str(&format!(
245 "vault_role={}\n",
246 Self::sanitize_value(§ion.vault_role)
247 ));
248 }
249 if !section.vault_addr.is_empty()
250 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
251 {
252 content.push_str(&format!(
253 "vault_addr={}\n",
254 Self::sanitize_value(§ion.vault_addr)
255 ));
256 }
257 if section.auto_sync != default_auto_sync(§ion.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 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
271 self.sections.iter().find(|s| s.provider == provider)
272 }
273
274 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 pub fn remove_section(&mut self, provider: &str) {
289 self.sections.retain(|s| s.provider != provider);
290 }
291
292 pub fn configured_providers(&self) -> &[ProviderSection] {
294 &self.sections
295 }
296}
297
298#[cfg(test)]
299#[path = "config_tests.rs"]
300mod tests;