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 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(§ion.provider)));
197 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
198 content.push_str(&format!(
199 "alias_prefix={}\n",
200 Self::sanitize_value(§ion.alias_prefix)
201 ));
202 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
203 if !section.identity_file.is_empty() {
204 content.push_str(&format!(
205 "key={}\n",
206 Self::sanitize_value(§ion.identity_file)
207 ));
208 }
209 if !section.url.is_empty() {
210 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.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(§ion.profile)
219 ));
220 }
221 if !section.regions.is_empty() {
222 content.push_str(&format!(
223 "regions={}\n",
224 Self::sanitize_value(§ion.regions)
225 ));
226 }
227 if !section.project.is_empty() {
228 content.push_str(&format!(
229 "project={}\n",
230 Self::sanitize_value(§ion.project)
231 ));
232 }
233 if !section.compartment.is_empty() {
234 content.push_str(&format!(
235 "compartment={}\n",
236 Self::sanitize_value(§ion.compartment)
237 ));
238 }
239 if !section.vault_role.is_empty()
240 && crate::vault_ssh::is_valid_role(§ion.vault_role)
241 {
242 content.push_str(&format!(
243 "vault_role={}\n",
244 Self::sanitize_value(§ion.vault_role)
245 ));
246 }
247 if !section.vault_addr.is_empty()
248 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
249 {
250 content.push_str(&format!(
251 "vault_addr={}\n",
252 Self::sanitize_value(§ion.vault_addr)
253 ));
254 }
255 if section.auto_sync != default_auto_sync(§ion.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 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
269 self.sections.iter().find(|s| s.provider == provider)
270 }
271
272 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 pub fn remove_section(&mut self, provider: &str) {
287 self.sections.retain(|s| s.provider != provider);
288 }
289
290 pub fn configured_providers(&self) -> &[ProviderSection] {
292 &self.sections
293 }
294}
295
296#[cfg(test)]
297#[path = "config_tests.rs"]
298mod tests;