1use std::fs::OpenOptions;
19use std::io::Read;
20use std::path::PathBuf;
21use std::sync::LazyLock;
22use std::{collections::BTreeMap, str::FromStr};
23
24use maplit::btreemap;
25use mz_ore::str::StrExt;
26use serde::{Deserialize, Serialize};
27use tokio::fs;
28use toml_edit::{DocumentMut, value};
29
30#[cfg(target_os = "macos")]
31use security_framework::passwords::{get_generic_password, set_generic_password};
32
33use crate::error::Error;
34
35#[cfg(target_os = "macos")]
39static KEYCHAIN_SERVICE_NAME: &str = "Materialize";
40
41#[cfg(target_os = "macos")]
44static OLD_KEYCHAIN_SERVICE_NAME: &str = "Materialize mz CLI";
45
46#[cfg(target_os = "macos")]
47static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> =
48 LazyLock::new(|| Some(Vault::Keychain.as_str()));
49
50#[cfg(not(target_os = "macos"))]
51static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> = LazyLock::new(|| Some(Vault::Inline.as_str()));
52
53static GLOBAL_PARAMS: LazyLock<BTreeMap<&'static str, GlobalParam>> = LazyLock::new(|| {
54 btreemap! {
55 "profile" => GlobalParam {
56 get: |config_file| {
57 config_file.profile.as_deref().or(Some("default"))
58 },
59 },
60 "vault" => GlobalParam {
61 get: |config_file| {
62 config_file.vault.as_deref().or(*DEFAULT_VAULT_VALUE)
63 },
64 }
65 }
66});
67
68#[derive(Clone)]
70pub struct ConfigFile {
71 path: PathBuf,
72 parsed: TomlConfigFile,
73 editable: DocumentMut,
74}
75
76impl ConfigFile {
77 pub fn default_path() -> Result<PathBuf, Error> {
79 let Some(mut path) = dirs::home_dir() else {
80 panic!("unable to discover home directory")
81 };
82 path.push(".config/materialize/mz.toml");
83 Ok(path)
84 }
85
86 pub async fn load(path: PathBuf) -> Result<ConfigFile, Error> {
88 if let Some(parent) = path.parent() {
90 if !parent.exists() {
91 fs::create_dir_all(parent).await?;
92 }
93 }
94
95 let mut file = OpenOptions::new()
97 .read(true)
98 .write(true)
99 .create(true)
100 .truncate(false)
101 .open(&path)?;
102
103 let mut buffer = String::new();
104 file.read_to_string(&mut buffer)?;
105
106 let parsed = toml_edit::de::from_str(&buffer)?;
107 let editable = buffer.parse()?;
108
109 Ok(ConfigFile {
110 path,
111 parsed,
112 editable,
113 })
114 }
115
116 pub fn load_profile<'a>(&'a self, name: &'a str) -> Result<Profile<'a>, Error> {
119 match &self.parsed.profiles {
120 Some(profiles) => match profiles.get(name) {
121 None => Err(Error::ProfileMissing(name.to_string())),
122 Some(parsed_profile) => Ok(Profile {
123 name,
124 parsed: parsed_profile,
125 }),
126 },
127 None => Err(Error::ProfilesMissing),
128 }
129 }
130
131 pub async fn add_profile(&self, name: String, profile: TomlProfile) -> Result<(), Error> {
133 let mut editable = self.editable.clone();
134
135 let profiles = editable.entry("profiles").or_insert(toml_edit::table());
136 let mut new_profile = toml_edit::Table::new();
137
138 self.add_app_password(&mut new_profile, &name, profile.clone())?;
139 new_profile["region"] = value(
140 profile
141 .region
142 .unwrap_or_else(|| "aws/us-east-1".to_string()),
143 );
144
145 if let Some(admin_endpoint) = profile.admin_endpoint {
146 new_profile["admin-endpoint"] = value(admin_endpoint);
147 }
148
149 if let Some(cloud_endpoint) = profile.cloud_endpoint {
150 new_profile["cloud-endpoint"] = value(cloud_endpoint);
151 }
152
153 if let Some(vault) = profile.vault {
154 new_profile["vault"] = value(vault.to_string());
155 }
156
157 profiles[name.clone()] = toml_edit::Item::Table(new_profile);
158 editable["profiles"] = profiles.clone();
159
160 editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
162
163 fs::write(&self.path, editable.to_string()).await?;
165
166 Ok(())
167 }
168
169 #[cfg(target_os = "macos")]
172 pub fn add_app_password(
173 &self,
174 new_profile: &mut toml_edit::Table,
175 name: &str,
176 profile: TomlProfile,
177 ) -> Result<(), Error> {
178 if Vault::Keychain == self.vault() {
179 let app_password = profile.app_password.ok_or(Error::AppPasswordMissing)?;
180 set_generic_password(KEYCHAIN_SERVICE_NAME, name, app_password.as_bytes())
181 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
182 } else {
183 new_profile["app-password"] =
184 value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
185 }
186
187 Ok(())
188 }
189
190 #[cfg(not(target_os = "macos"))]
192 pub fn add_app_password(
193 &self,
194 new_profile: &mut toml_edit::Table,
195 _name: &str,
197 profile: TomlProfile,
198 ) -> Result<(), Error> {
199 new_profile["app-password"] = value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
200
201 Ok(())
202 }
203
204 pub async fn remove_profile(&self, name: &str) -> Result<(), Error> {
206 let mut editable = self.editable.clone();
207 let profiles = editable["profiles"]
208 .as_table_mut()
209 .ok_or(Error::ProfilesMissing)?;
210 profiles.remove(name);
211
212 fs::write(&self.path, editable.to_string()).await?;
213
214 Ok(())
215 }
216
217 pub fn profile(&self) -> &str {
219 (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
220 }
221
222 pub fn vault(&self) -> &str {
224 (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
225 }
226
227 pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
229 self.parsed.profiles.clone()
230 }
231
232 pub fn list_profile_params(
234 &self,
235 profile_name: &str,
236 ) -> Result<Vec<(&str, Option<String>)>, Error> {
237 let profile = self
240 .parsed
241 .profiles
242 .clone()
243 .ok_or(Error::ProfilesMissing)?
244 .get(profile_name)
245 .ok_or(Error::ProfileMissing(self.profile().to_string()))?
246 .clone();
247
248 let out = vec![
249 ("admin-endpoint", profile.admin_endpoint),
250 ("app-password", profile.app_password),
251 ("cloud-endpoint", profile.cloud_endpoint),
252 ("region", profile.region),
253 ("vault", profile.vault.map(|x| x.to_string())),
254 ];
255
256 Ok(out)
257 }
258
259 pub fn get_profile_param<'a>(
261 &'a self,
262 name: &str,
263 profile: &'a str,
264 ) -> Result<Option<&'a str>, Error> {
265 let profile = self.load_profile(profile)?;
266 let value = (PROFILE_PARAMS[name].get)(profile.parsed);
267
268 Ok(value)
269 }
270
271 pub async fn set_profile_param(
273 &self,
274 profile_name: &str,
275 name: &str,
276 value: Option<&str>,
277 ) -> Result<(), Error> {
278 let mut editable = self.editable.clone();
279
280 match value {
282 None => {
283 let profile = editable["profiles"][profile_name]
284 .as_table_mut()
285 .ok_or(Error::ProfileMissing(name.to_string()))?;
286 if profile.contains_key(name) {
287 profile.remove(name);
288 }
289 }
290 Some(value) => editable["profiles"][profile_name][name] = toml_edit::value(value),
291 }
292
293 fs::write(&self.path, editable.to_string()).await?;
294
295 Ok(())
296 }
297
298 pub fn get_param(&self, name: &str) -> Result<Option<&str>, Error> {
300 match GLOBAL_PARAMS.get(name) {
301 Some(param) => Ok((param.get)(&self.parsed)),
302 None => panic!("unknown configuration parameter {}", name.quoted()),
303 }
304 }
305
306 pub fn list_params(&self) -> Vec<(&str, Option<&str>)> {
308 let mut out = vec![];
309 for (name, param) in &*GLOBAL_PARAMS {
310 out.push((*name, (param.get)(&self.parsed)));
311 }
312 out
313 }
314
315 pub async fn set_param(&self, name: &str, value: Option<&str>) -> Result<(), Error> {
317 if !GLOBAL_PARAMS.contains_key(name) {
318 panic!("unknown configuration parameter {}", name.quoted());
319 }
320 let mut editable = self.editable.clone();
321 match value {
322 None => {
323 editable.remove(name);
324 }
325 Some(value) => editable[name] = toml_edit::value(value),
326 }
327 fs::write(&self.path, editable.to_string()).await?;
328 Ok(())
329 }
330}
331
332static PROFILE_PARAMS: LazyLock<BTreeMap<&'static str, ProfileParam>> = LazyLock::new(|| {
333 btreemap! {
334 "app-password" => ProfileParam {
335 get: |t| t.app_password.as_deref(),
336 },
337 "region" => ProfileParam {
338 get: |t| t.region.as_deref(),
339 },
340 "vault" => ProfileParam {
341 get: |t| t.vault.clone().map(|x| x.as_str()),
342 },
343 "admin-endpoint" => ProfileParam {
344 get: |t| t.admin_endpoint.as_deref(),
345 },
346 "cloud-endpoint" => ProfileParam {
347 get: |t| t.cloud_endpoint.as_deref(),
348 },
349 }
350});
351
352pub struct Profile<'a> {
358 name: &'a str,
359 parsed: &'a TomlProfile,
360}
361
362impl Profile<'_> {
363 pub fn name(&self) -> &str {
365 self.name
366 }
367
368 #[cfg(target_os = "macos")]
370 pub fn app_password(&self, global_vault: &str) -> Result<String, Error> {
371 if let Some(vault) = self.vault().or(Some(global_vault)) {
372 if vault == Vault::Keychain {
373 let password = get_generic_password(KEYCHAIN_SERVICE_NAME, self.name);
374
375 match password {
376 Ok(generic_password) => {
377 let parsed_password = String::from_utf8(generic_password.to_vec());
378 match parsed_password {
379 Ok(app_password) => return Ok(app_password),
380 Err(err) => return Err(Error::MacOsSecurityError(err.to_string())),
381 }
382 }
383 Err(err) => {
384 if err.code() == -25300 {
386 let password =
387 get_generic_password(OLD_KEYCHAIN_SERVICE_NAME, self.name);
388 if let Ok(generic_password) = password {
389 let parsed_password = String::from_utf8(generic_password.to_vec());
390
391 match parsed_password {
393 Ok(app_password) => {
394 set_generic_password(
395 KEYCHAIN_SERVICE_NAME,
396 self.name,
397 app_password.as_bytes(),
398 )
399 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
400 return Ok(app_password);
401 }
402 Err(err) => {
403 return Err(Error::MacOsSecurityError(err.to_string()));
404 }
405 }
406 }
407 }
408
409 return Err(Error::MacOsSecurityError(err.to_string()));
410 }
411 }
412 }
413 }
414
415 (PROFILE_PARAMS["app-password"].get)(self.parsed)
416 .map(|x| x.to_string())
417 .ok_or(Error::AppPasswordMissing)
418 }
419
420 #[cfg(not(target_os = "macos"))]
422 pub fn app_password(&self, _global_vault: &str) -> Result<String, Error> {
423 (PROFILE_PARAMS["app-password"].get)(self.parsed)
424 .map(|x| x.to_string())
425 .ok_or(Error::AppPasswordMissing)
426 }
427
428 pub fn region(&self) -> Option<&str> {
430 (PROFILE_PARAMS["region"].get)(self.parsed)
431 }
432
433 pub fn vault(&self) -> Option<&str> {
435 (PROFILE_PARAMS["vault"].get)(self.parsed)
436 }
437
438 pub fn admin_endpoint(&self) -> Option<&str> {
440 (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
441 }
442
443 pub fn cloud_endpoint(&self) -> Option<&str> {
445 (PROFILE_PARAMS["cloud-endpoint"].get)(self.parsed)
446 }
447}
448
449struct ConfigParam<T> {
450 get: fn(&T) -> Option<&str>,
451}
452
453type GlobalParam = ConfigParam<TomlConfigFile>;
454type ProfileParam = ConfigParam<TomlProfile>;
455
456#[derive(Clone, Deserialize, Debug, Serialize)]
459#[serde(rename_all = "lowercase")]
460pub enum Vault {
461 Keychain,
463 Inline,
465}
466
467impl ToString for Vault {
468 fn to_string(&self) -> String {
469 match self {
470 Vault::Keychain => "keychain".to_string(),
471 Vault::Inline => "inline".to_string(),
472 }
473 }
474}
475
476impl Vault {
477 fn as_str(&self) -> &'static str {
478 match self {
479 Vault::Keychain => "keychain",
480 Vault::Inline => "inline",
481 }
482 }
483}
484
485impl FromStr for Vault {
486 type Err = crate::error::Error;
487 fn from_str(s: &str) -> Result<Self, crate::error::Error> {
488 match s.to_ascii_lowercase().as_str() {
489 "keychain" => Ok(Vault::Keychain),
490 "inline" => Ok(Vault::Inline),
491 _ => Err(Error::InvalidVaultError),
492 }
493 }
494}
495
496impl PartialEq<&str> for Vault {
497 fn eq(&self, other: &&str) -> bool {
498 self.as_str() == *other
499 }
500}
501
502impl PartialEq<Vault> for &str {
503 fn eq(&self, other: &Vault) -> bool {
504 self == &other.as_str()
505 }
506}
507
508#[derive(Clone, Debug, Deserialize, Serialize)]
509#[serde(deny_unknown_fields)]
510#[serde(rename_all = "kebab-case")]
511struct TomlConfigFile {
512 profile: Option<String>,
513 vault: Option<String>,
514 profiles: Option<BTreeMap<String, TomlProfile>>,
515}
516
517#[derive(Debug, Deserialize, Serialize, Clone)]
518#[serde(rename_all = "kebab-case")]
519#[serde(deny_unknown_fields)]
520pub struct TomlProfile {
522 pub app_password: Option<String>,
524 pub region: Option<String>,
526 pub vault: Option<Vault>,
528 pub admin_endpoint: Option<String>,
530 pub cloud_endpoint: Option<String>,
532}