1use std::path::PathBuf;
19use std::sync::LazyLock;
20use std::{collections::BTreeMap, str::FromStr};
21
22use maplit::btreemap;
23use mz_ore::str::StrExt;
24use serde::{Deserialize, Serialize};
25use tokio::fs;
26use toml_edit::{DocumentMut, value};
27
28#[cfg(target_os = "macos")]
29use security_framework::passwords::{get_generic_password, set_generic_password};
30
31use crate::error::Error;
32
33#[cfg(target_os = "macos")]
37static KEYCHAIN_SERVICE_NAME: &str = "Materialize";
38
39#[cfg(target_os = "macos")]
42static OLD_KEYCHAIN_SERVICE_NAME: &str = "Materialize mz CLI";
43
44#[cfg(target_os = "macos")]
45static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> =
46 LazyLock::new(|| Some(Vault::Keychain.as_str()));
47
48#[cfg(not(target_os = "macos"))]
49static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> = LazyLock::new(|| Some(Vault::Inline.as_str()));
50
51static GLOBAL_PARAMS: LazyLock<BTreeMap<&'static str, GlobalParam>> = LazyLock::new(|| {
52 btreemap! {
53 "profile" => GlobalParam {
54 get: |config_file| {
55 config_file.profile.as_deref().or(Some("default"))
56 },
57 },
58 "vault" => GlobalParam {
59 get: |config_file| {
60 config_file.vault.as_deref().or(*DEFAULT_VAULT_VALUE)
61 },
62 }
63 }
64});
65
66#[derive(Clone)]
68pub struct ConfigFile {
69 path: PathBuf,
70 parsed: TomlConfigFile,
71 editable: DocumentMut,
72}
73
74impl ConfigFile {
75 pub fn default_path() -> Result<PathBuf, Error> {
77 let Some(mut path) = dirs::home_dir() else {
78 panic!("unable to discover home directory")
79 };
80 path.push(".config/materialize/mz.toml");
81 Ok(path)
82 }
83
84 pub async fn load(path: PathBuf) -> Result<ConfigFile, Error> {
91 let buffer = match fs::read_to_string(&path).await {
92 Ok(buffer) => buffer,
93 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
94 Err(e) => return Err(e.into()),
95 };
96
97 let parsed = toml_edit::de::from_str(&buffer)?;
98 let editable = buffer.parse()?;
99
100 Ok(ConfigFile {
101 path,
102 parsed,
103 editable,
104 })
105 }
106
107 async fn write(&self, contents: String) -> Result<(), Error> {
110 if let Some(parent) = self.path.parent() {
111 fs::create_dir_all(parent).await?;
112 }
113 fs::write(&self.path, contents).await?;
114
115 Ok(())
116 }
117
118 pub async fn ensure_writable(&self) -> Result<(), Error> {
132 let result = async {
133 if let Some(parent) = self.path.parent() {
134 fs::create_dir_all(parent).await?;
135 }
136 fs::OpenOptions::new()
137 .write(true)
138 .create(true)
139 .truncate(false)
140 .open(&self.path)
141 .await?;
142
143 Ok::<(), std::io::Error>(())
144 };
145
146 result
147 .await
148 .map_err(|e| Error::ConfigFileNotWritable(self.path.clone(), e))
149 }
150
151 pub fn load_profile<'a>(&'a self, name: &'a str) -> Result<Profile<'a>, Error> {
154 match &self.parsed.profiles {
155 Some(profiles) => match profiles.get(name) {
156 None => Err(Error::ProfileMissing(name.to_string())),
157 Some(parsed_profile) => Ok(Profile {
158 name,
159 parsed: parsed_profile,
160 }),
161 },
162 None => Err(Error::ProfilesMissing),
163 }
164 }
165
166 pub async fn add_profile(&self, name: String, profile: TomlProfile) -> Result<(), Error> {
168 let mut editable = self.editable.clone();
169
170 let profiles = editable.entry("profiles").or_insert(toml_edit::table());
171 let mut new_profile = toml_edit::Table::new();
172
173 self.add_app_password(&mut new_profile, &name, profile.clone())?;
174 new_profile["region"] = value(
175 profile
176 .region
177 .unwrap_or_else(|| "aws/us-east-1".to_string()),
178 );
179
180 if let Some(admin_endpoint) = profile.admin_endpoint {
181 new_profile["admin-endpoint"] = value(admin_endpoint);
182 }
183
184 if let Some(cloud_endpoint) = profile.cloud_endpoint {
185 new_profile["cloud-endpoint"] = value(cloud_endpoint);
186 }
187
188 if let Some(vault) = profile.vault {
189 new_profile["vault"] = value(vault.to_string());
190 }
191
192 profiles[name.clone()] = toml_edit::Item::Table(new_profile);
193 editable["profiles"] = profiles.clone();
194
195 editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
197
198 self.write(editable.to_string()).await?;
200
201 Ok(())
202 }
203
204 #[cfg(target_os = "macos")]
207 pub fn add_app_password(
208 &self,
209 new_profile: &mut toml_edit::Table,
210 name: &str,
211 profile: TomlProfile,
212 ) -> Result<(), Error> {
213 if Vault::Keychain == self.vault() {
214 let app_password = profile.app_password.ok_or(Error::AppPasswordMissing)?;
215 set_generic_password(KEYCHAIN_SERVICE_NAME, name, app_password.as_bytes())
216 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
217 } else {
218 new_profile["app-password"] =
219 value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
220 }
221
222 Ok(())
223 }
224
225 #[cfg(not(target_os = "macos"))]
227 pub fn add_app_password(
228 &self,
229 new_profile: &mut toml_edit::Table,
230 _name: &str,
232 profile: TomlProfile,
233 ) -> Result<(), Error> {
234 new_profile["app-password"] = value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
235
236 Ok(())
237 }
238
239 pub async fn remove_profile(&self, name: &str) -> Result<(), Error> {
241 let mut editable = self.editable.clone();
242 let profiles = editable["profiles"]
243 .as_table_mut()
244 .ok_or(Error::ProfilesMissing)?;
245 profiles.remove(name);
246
247 self.write(editable.to_string()).await?;
248
249 Ok(())
250 }
251
252 pub fn profile(&self) -> &str {
254 (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
255 }
256
257 pub fn vault(&self) -> &str {
259 (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
260 }
261
262 pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
264 self.parsed.profiles.clone()
265 }
266
267 pub fn list_profile_params(
269 &self,
270 profile_name: &str,
271 ) -> Result<Vec<(&str, Option<String>)>, Error> {
272 let profile = self
275 .parsed
276 .profiles
277 .clone()
278 .ok_or(Error::ProfilesMissing)?
279 .get(profile_name)
280 .ok_or(Error::ProfileMissing(self.profile().to_string()))?
281 .clone();
282
283 let out = vec![
284 ("admin-endpoint", profile.admin_endpoint),
285 ("app-password", profile.app_password),
286 ("cloud-endpoint", profile.cloud_endpoint),
287 ("region", profile.region),
288 ("vault", profile.vault.map(|x| x.to_string())),
289 ];
290
291 Ok(out)
292 }
293
294 pub fn get_profile_param<'a>(
296 &'a self,
297 name: &str,
298 profile: &'a str,
299 ) -> Result<Option<&'a str>, Error> {
300 let profile = self.load_profile(profile)?;
301 let value = (PROFILE_PARAMS[name].get)(profile.parsed);
302
303 Ok(value)
304 }
305
306 pub async fn set_profile_param(
308 &self,
309 profile_name: &str,
310 name: &str,
311 value: Option<&str>,
312 ) -> Result<(), Error> {
313 let mut editable = self.editable.clone();
314
315 match value {
317 None => {
318 let profile = editable["profiles"][profile_name]
319 .as_table_mut()
320 .ok_or(Error::ProfileMissing(name.to_string()))?;
321 if profile.contains_key(name) {
322 profile.remove(name);
323 }
324 }
325 Some(value) => editable["profiles"][profile_name][name] = toml_edit::value(value),
326 }
327
328 self.write(editable.to_string()).await?;
329
330 Ok(())
331 }
332
333 pub fn get_param(&self, name: &str) -> Result<Option<&str>, Error> {
335 match GLOBAL_PARAMS.get(name) {
336 Some(param) => Ok((param.get)(&self.parsed)),
337 None => panic!("unknown configuration parameter {}", name.quoted()),
338 }
339 }
340
341 pub fn list_params(&self) -> Vec<(&str, Option<&str>)> {
343 let mut out = vec![];
344 for (name, param) in &*GLOBAL_PARAMS {
345 out.push((*name, (param.get)(&self.parsed)));
346 }
347 out
348 }
349
350 pub async fn set_param(&self, name: &str, value: Option<&str>) -> Result<(), Error> {
352 if !GLOBAL_PARAMS.contains_key(name) {
353 panic!("unknown configuration parameter {}", name.quoted());
354 }
355 let mut editable = self.editable.clone();
356 match value {
357 None => {
358 editable.remove(name);
359 }
360 Some(value) => editable[name] = toml_edit::value(value),
361 }
362 self.write(editable.to_string()).await?;
363 Ok(())
364 }
365}
366
367static PROFILE_PARAMS: LazyLock<BTreeMap<&'static str, ProfileParam>> = LazyLock::new(|| {
368 btreemap! {
369 "app-password" => ProfileParam {
370 get: |t| t.app_password.as_deref(),
371 },
372 "region" => ProfileParam {
373 get: |t| t.region.as_deref(),
374 },
375 "vault" => ProfileParam {
376 get: |t| t.vault.clone().map(|x| x.as_str()),
377 },
378 "admin-endpoint" => ProfileParam {
379 get: |t| t.admin_endpoint.as_deref(),
380 },
381 "cloud-endpoint" => ProfileParam {
382 get: |t| t.cloud_endpoint.as_deref(),
383 },
384 }
385});
386
387pub struct Profile<'a> {
393 name: &'a str,
394 parsed: &'a TomlProfile,
395}
396
397impl Profile<'_> {
398 pub fn name(&self) -> &str {
400 self.name
401 }
402
403 #[cfg(target_os = "macos")]
405 pub fn app_password(&self, global_vault: &str) -> Result<String, Error> {
406 if let Some(vault) = self.vault().or(Some(global_vault)) {
407 if vault == Vault::Keychain {
408 let password = get_generic_password(KEYCHAIN_SERVICE_NAME, self.name);
409
410 match password {
411 Ok(generic_password) => {
412 let parsed_password = String::from_utf8(generic_password.to_vec());
413 match parsed_password {
414 Ok(app_password) => return Ok(app_password),
415 Err(err) => return Err(Error::MacOsSecurityError(err.to_string())),
416 }
417 }
418 Err(err) => {
419 if err.code() == -25300 {
421 let password =
422 get_generic_password(OLD_KEYCHAIN_SERVICE_NAME, self.name);
423 if let Ok(generic_password) = password {
424 let parsed_password = String::from_utf8(generic_password.to_vec());
425
426 match parsed_password {
428 Ok(app_password) => {
429 set_generic_password(
430 KEYCHAIN_SERVICE_NAME,
431 self.name,
432 app_password.as_bytes(),
433 )
434 .map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
435 return Ok(app_password);
436 }
437 Err(err) => {
438 return Err(Error::MacOsSecurityError(err.to_string()));
439 }
440 }
441 }
442 }
443
444 return Err(Error::MacOsSecurityError(err.to_string()));
445 }
446 }
447 }
448 }
449
450 (PROFILE_PARAMS["app-password"].get)(self.parsed)
451 .map(|x| x.to_string())
452 .ok_or(Error::AppPasswordMissing)
453 }
454
455 #[cfg(not(target_os = "macos"))]
457 pub fn app_password(&self, _global_vault: &str) -> Result<String, Error> {
458 (PROFILE_PARAMS["app-password"].get)(self.parsed)
459 .map(|x| x.to_string())
460 .ok_or(Error::AppPasswordMissing)
461 }
462
463 pub fn region(&self) -> Option<&str> {
465 (PROFILE_PARAMS["region"].get)(self.parsed)
466 }
467
468 pub fn vault(&self) -> Option<&str> {
470 (PROFILE_PARAMS["vault"].get)(self.parsed)
471 }
472
473 pub fn admin_endpoint(&self) -> Option<&str> {
475 (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
476 }
477
478 pub fn cloud_endpoint(&self) -> Option<&str> {
480 (PROFILE_PARAMS["cloud-endpoint"].get)(self.parsed)
481 }
482}
483
484struct ConfigParam<T> {
485 get: fn(&T) -> Option<&str>,
486}
487
488type GlobalParam = ConfigParam<TomlConfigFile>;
489type ProfileParam = ConfigParam<TomlProfile>;
490
491#[derive(Clone, Deserialize, Debug, Serialize)]
494#[serde(rename_all = "lowercase")]
495pub enum Vault {
496 Keychain,
498 Inline,
500}
501
502impl ToString for Vault {
503 fn to_string(&self) -> String {
504 match self {
505 Vault::Keychain => "keychain".to_string(),
506 Vault::Inline => "inline".to_string(),
507 }
508 }
509}
510
511impl Vault {
512 fn as_str(&self) -> &'static str {
513 match self {
514 Vault::Keychain => "keychain",
515 Vault::Inline => "inline",
516 }
517 }
518}
519
520impl FromStr for Vault {
521 type Err = crate::error::Error;
522 fn from_str(s: &str) -> Result<Self, crate::error::Error> {
523 match s.to_ascii_lowercase().as_str() {
524 "keychain" => Ok(Vault::Keychain),
525 "inline" => Ok(Vault::Inline),
526 _ => Err(Error::InvalidVaultError),
527 }
528 }
529}
530
531impl PartialEq<&str> for Vault {
532 fn eq(&self, other: &&str) -> bool {
533 self.as_str() == *other
534 }
535}
536
537impl PartialEq<Vault> for &str {
538 fn eq(&self, other: &Vault) -> bool {
539 self == &other.as_str()
540 }
541}
542
543#[derive(Clone, Debug, Deserialize, Serialize)]
544#[serde(deny_unknown_fields)]
545#[serde(rename_all = "kebab-case")]
546struct TomlConfigFile {
547 profile: Option<String>,
548 vault: Option<String>,
549 profiles: Option<BTreeMap<String, TomlProfile>>,
550}
551
552#[derive(Debug, Deserialize, Serialize, Clone)]
553#[serde(rename_all = "kebab-case")]
554#[serde(deny_unknown_fields)]
555pub struct TomlProfile {
557 pub app_password: Option<String>,
559 pub region: Option<String>,
561 pub vault: Option<Vault>,
563 pub admin_endpoint: Option<String>,
565 pub cloud_endpoint: Option<String>,
567}
568
569#[cfg(test)]
570mod tests {
571 use tempfile::TempDir;
572
573 use super::*;
574
575 fn permissions_are_enforced(path: &PathBuf) -> bool {
578 std::fs::OpenOptions::new().write(true).open(path).is_err()
579 }
580
581 fn make_read_only(path: &PathBuf) -> bool {
584 let mut permissions = std::fs::metadata(path).unwrap().permissions();
585 permissions.set_readonly(true);
586 std::fs::set_permissions(path, permissions).unwrap();
587
588 if path.is_dir() {
589 let probe = path.join("probe");
590 let enforced = std::fs::write(&probe, "").is_err();
591 let _ = std::fs::remove_file(probe);
592 enforced
593 } else {
594 permissions_are_enforced(path)
595 }
596 }
597
598 fn make_writable(path: &PathBuf) {
600 let mut permissions = std::fs::metadata(path).unwrap().permissions();
601 #[allow(clippy::permissions_set_readonly_false)]
602 permissions.set_readonly(false);
603 std::fs::set_permissions(path, permissions).unwrap();
604 }
605
606 #[mz_ore::test(tokio::test)]
607 #[cfg_attr(miri, ignore)] async fn test_load_missing_file() {
609 let dir = TempDir::new().unwrap();
610 let path = dir.path().join("missing").join("mz.toml");
611
612 let config = ConfigFile::load(path.clone()).await.unwrap();
613
614 assert!(config.profiles().is_none());
615 assert!(!path.exists());
617 assert!(!path.parent().unwrap().exists());
618 }
619
620 #[mz_ore::test(tokio::test)]
621 #[cfg_attr(miri, ignore)] async fn test_load_read_only_file() {
623 let dir = TempDir::new().unwrap();
624 let path = dir.path().join("mz.toml");
625 std::fs::write(&path, "profile = \"default\"\n").unwrap();
626 let enforced = make_read_only(&path);
627
628 let config = ConfigFile::load(path.clone()).await.unwrap();
629
630 assert_eq!(config.profile(), "default");
631 if enforced {
632 assert!(config.ensure_writable().await.is_err());
633 }
634
635 make_writable(&path);
636 }
637
638 #[mz_ore::test(tokio::test)]
639 #[cfg_attr(miri, ignore)] async fn test_missing_file_in_read_only_directory() {
641 let dir = TempDir::new().unwrap();
642 let parent = dir.path().join("materialize");
643 std::fs::create_dir(&parent).unwrap();
644 let enforced = make_read_only(&parent);
645
646 let config = ConfigFile::load(parent.join("mz.toml")).await.unwrap();
647
648 if enforced {
650 assert!(config.ensure_writable().await.is_err());
651 }
652
653 make_writable(&parent);
654 }
655
656 #[mz_ore::test(tokio::test)]
657 #[cfg_attr(miri, ignore)] async fn test_write_creates_parent_directory() {
659 let dir = TempDir::new().unwrap();
660 let path = dir.path().join("materialize").join("mz.toml");
661
662 let config = ConfigFile::load(path.clone()).await.unwrap();
663 config.ensure_writable().await.unwrap();
664 assert!(path.exists());
666
667 config.set_param("profile", Some("default")).await.unwrap();
668
669 assert_eq!(ConfigFile::load(path).await.unwrap().profile(), "default");
670 }
671}