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(profile.region.unwrap_or("aws/us-east-1".to_string()));
140
141 if let Some(admin_endpoint) = profile.admin_endpoint {
142 new_profile["admin-endpoint"] = value(admin_endpoint);
143 }
144
145 if let Some(cloud_endpoint) = profile.cloud_endpoint {
146 new_profile["cloud-endpoint"] = value(cloud_endpoint);
147 }
148
149 if let Some(vault) = profile.vault {
150 new_profile["vault"] = value(vault.to_string());
151 }
152
153 profiles[name.clone()] = toml_edit::Item::Table(new_profile);
154 editable["profiles"] = profiles.clone();
155
156 editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
158
159 fs::write(&self.path, editable.to_string()).await?;
161
162 Ok(())
163 }
164
165 #[cfg(target_os = "macos")]
168 pub fn add_app_password(
169 &self,
170 new_profile: &mut toml_edit::Table,
171 name: &str,
172 profile: TomlProfile,
173 ) -> Result<(), Error> {
174 if Vault::Keychain == self.vault() {
175 let app_password = profile.app_password.ok_or(Error::AppPasswordMissing)?;
176 set_generic_password(KEYCHAIN_SERVICE_NAME, name, app_password.as_bytes())
177 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
178 } else {
179 new_profile["app-password"] =
180 value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
181 }
182
183 Ok(())
184 }
185
186 #[cfg(not(target_os = "macos"))]
188 pub fn add_app_password(
189 &self,
190 new_profile: &mut toml_edit::Table,
191 _name: &str,
193 profile: TomlProfile,
194 ) -> Result<(), Error> {
195 new_profile["app-password"] = value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
196
197 Ok(())
198 }
199
200 pub async fn remove_profile(&self, name: &str) -> Result<(), Error> {
202 let mut editable = self.editable.clone();
203 let profiles = editable["profiles"]
204 .as_table_mut()
205 .ok_or(Error::ProfilesMissing)?;
206 profiles.remove(name);
207
208 fs::write(&self.path, editable.to_string()).await?;
209
210 Ok(())
211 }
212
213 pub fn profile(&self) -> &str {
215 (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
216 }
217
218 pub fn vault(&self) -> &str {
220 (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
221 }
222
223 pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
225 self.parsed.profiles.clone()
226 }
227
228 pub fn list_profile_params(
230 &self,
231 profile_name: &str,
232 ) -> Result<Vec<(&str, Option<String>)>, Error> {
233 let profile = self
236 .parsed
237 .profiles
238 .clone()
239 .ok_or(Error::ProfilesMissing)?
240 .get(profile_name)
241 .ok_or(Error::ProfileMissing(self.profile().to_string()))?
242 .clone();
243
244 let out = vec![
245 ("admin-endpoint", profile.admin_endpoint),
246 ("app-password", profile.app_password),
247 ("cloud-endpoint", profile.cloud_endpoint),
248 ("region", profile.region),
249 ("vault", profile.vault.map(|x| x.to_string())),
250 ];
251
252 Ok(out)
253 }
254
255 pub fn get_profile_param<'a>(
257 &'a self,
258 name: &str,
259 profile: &'a str,
260 ) -> Result<Option<&'a str>, Error> {
261 let profile = self.load_profile(profile)?;
262 let value = (PROFILE_PARAMS[name].get)(profile.parsed);
263
264 Ok(value)
265 }
266
267 pub async fn set_profile_param(
269 &self,
270 profile_name: &str,
271 name: &str,
272 value: Option<&str>,
273 ) -> Result<(), Error> {
274 let mut editable = self.editable.clone();
275
276 match value {
278 None => {
279 let profile = editable["profiles"][profile_name]
280 .as_table_mut()
281 .ok_or(Error::ProfileMissing(name.to_string()))?;
282 if profile.contains_key(name) {
283 profile.remove(name);
284 }
285 }
286 Some(value) => editable["profiles"][profile_name][name] = toml_edit::value(value),
287 }
288
289 fs::write(&self.path, editable.to_string()).await?;
290
291 Ok(())
292 }
293
294 pub fn get_param(&self, name: &str) -> Result<Option<&str>, Error> {
296 match GLOBAL_PARAMS.get(name) {
297 Some(param) => Ok((param.get)(&self.parsed)),
298 None => panic!("unknown configuration parameter {}", name.quoted()),
299 }
300 }
301
302 pub fn list_params(&self) -> Vec<(&str, Option<&str>)> {
304 let mut out = vec![];
305 for (name, param) in &*GLOBAL_PARAMS {
306 out.push((*name, (param.get)(&self.parsed)));
307 }
308 out
309 }
310
311 pub async fn set_param(&self, name: &str, value: Option<&str>) -> Result<(), Error> {
313 if !GLOBAL_PARAMS.contains_key(name) {
314 panic!("unknown configuration parameter {}", name.quoted());
315 }
316 let mut editable = self.editable.clone();
317 match value {
318 None => {
319 editable.remove(name);
320 }
321 Some(value) => editable[name] = toml_edit::value(value),
322 }
323 fs::write(&self.path, editable.to_string()).await?;
324 Ok(())
325 }
326}
327
328static PROFILE_PARAMS: LazyLock<BTreeMap<&'static str, ProfileParam>> = LazyLock::new(|| {
329 btreemap! {
330 "app-password" => ProfileParam {
331 get: |t| t.app_password.as_deref(),
332 },
333 "region" => ProfileParam {
334 get: |t| t.region.as_deref(),
335 },
336 "vault" => ProfileParam {
337 get: |t| t.vault.clone().map(|x| x.as_str()),
338 },
339 "admin-endpoint" => ProfileParam {
340 get: |t| t.admin_endpoint.as_deref(),
341 },
342 "cloud-endpoint" => ProfileParam {
343 get: |t| t.cloud_endpoint.as_deref(),
344 },
345 }
346});
347
348pub struct Profile<'a> {
354 name: &'a str,
355 parsed: &'a TomlProfile,
356}
357
358impl Profile<'_> {
359 pub fn name(&self) -> &str {
361 self.name
362 }
363
364 #[cfg(target_os = "macos")]
366 pub fn app_password(&self, global_vault: &str) -> Result<String, Error> {
367 if let Some(vault) = self.vault().or(Some(global_vault)) {
368 if vault == Vault::Keychain {
369 let password = get_generic_password(KEYCHAIN_SERVICE_NAME, self.name);
370
371 match password {
372 Ok(generic_password) => {
373 let parsed_password = String::from_utf8(generic_password.to_vec());
374 match parsed_password {
375 Ok(app_password) => return Ok(app_password),
376 Err(err) => return Err(Error::MacOsSecurityError(err.to_string())),
377 }
378 }
379 Err(err) => {
380 if err.code() == -25300 {
382 let password =
383 get_generic_password(OLD_KEYCHAIN_SERVICE_NAME, self.name);
384 if let Ok(generic_password) = password {
385 let parsed_password = String::from_utf8(generic_password.to_vec());
386
387 match parsed_password {
389 Ok(app_password) => {
390 set_generic_password(
391 KEYCHAIN_SERVICE_NAME,
392 self.name,
393 app_password.as_bytes(),
394 )
395 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
396 return Ok(app_password);
397 }
398 Err(err) => {
399 return Err(Error::MacOsSecurityError(err.to_string()));
400 }
401 }
402 }
403 }
404
405 return Err(Error::MacOsSecurityError(err.to_string()));
406 }
407 }
408 }
409 }
410
411 (PROFILE_PARAMS["app-password"].get)(self.parsed)
412 .map(|x| x.to_string())
413 .ok_or(Error::AppPasswordMissing)
414 }
415
416 #[cfg(not(target_os = "macos"))]
418 pub fn app_password(&self, _global_vault: &str) -> Result<String, Error> {
419 (PROFILE_PARAMS["app-password"].get)(self.parsed)
420 .map(|x| x.to_string())
421 .ok_or(Error::AppPasswordMissing)
422 }
423
424 pub fn region(&self) -> Option<&str> {
426 (PROFILE_PARAMS["region"].get)(self.parsed)
427 }
428
429 pub fn vault(&self) -> Option<&str> {
431 (PROFILE_PARAMS["vault"].get)(self.parsed)
432 }
433
434 pub fn admin_endpoint(&self) -> Option<&str> {
436 (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
437 }
438
439 pub fn cloud_endpoint(&self) -> Option<&str> {
441 (PROFILE_PARAMS["cloud-endpoint"].get)(self.parsed)
442 }
443}
444
445struct ConfigParam<T> {
446 get: fn(&T) -> Option<&str>,
447}
448
449type GlobalParam = ConfigParam<TomlConfigFile>;
450type ProfileParam = ConfigParam<TomlProfile>;
451
452#[derive(Clone, Deserialize, Debug, Serialize)]
455#[serde(rename_all = "lowercase")]
456pub enum Vault {
457 Keychain,
459 Inline,
461}
462
463impl ToString for Vault {
464 fn to_string(&self) -> String {
465 match self {
466 Vault::Keychain => "keychain".to_string(),
467 Vault::Inline => "inline".to_string(),
468 }
469 }
470}
471
472impl Vault {
473 fn as_str(&self) -> &'static str {
474 match self {
475 Vault::Keychain => "keychain",
476 Vault::Inline => "inline",
477 }
478 }
479}
480
481impl FromStr for Vault {
482 type Err = crate::error::Error;
483 fn from_str(s: &str) -> Result<Self, crate::error::Error> {
484 match s.to_ascii_lowercase().as_str() {
485 "keychain" => Ok(Vault::Keychain),
486 "inline" => Ok(Vault::Inline),
487 _ => Err(Error::InvalidVaultError),
488 }
489 }
490}
491
492impl PartialEq<&str> for Vault {
493 fn eq(&self, other: &&str) -> bool {
494 self.as_str() == *other
495 }
496}
497
498impl PartialEq<Vault> for &str {
499 fn eq(&self, other: &Vault) -> bool {
500 self == &other.as_str()
501 }
502}
503
504#[derive(Clone, Debug, Deserialize, Serialize)]
505#[serde(deny_unknown_fields)]
506#[serde(rename_all = "kebab-case")]
507struct TomlConfigFile {
508 profile: Option<String>,
509 vault: Option<String>,
510 profiles: Option<BTreeMap<String, TomlProfile>>,
511}
512
513#[derive(Debug, Deserialize, Serialize, Clone)]
514#[serde(rename_all = "kebab-case")]
515#[serde(deny_unknown_fields)]
516pub struct TomlProfile {
518 pub app_password: Option<String>,
520 pub region: Option<String>,
522 pub vault: Option<Vault>,
524 pub admin_endpoint: Option<String>,
526 pub cloud_endpoint: Option<String>,
528}