use std::fs::OpenOptions;
use std::io::Read;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::{collections::BTreeMap, str::FromStr};
use maplit::btreemap;
use mz_ore::str::StrExt;
use serde::{Deserialize, Serialize};
use tokio::fs;
use toml_edit::{value, Document};
#[cfg(target_os = "macos")]
use security_framework::passwords::{get_generic_password, set_generic_password};
use crate::error::Error;
#[cfg(target_os = "macos")]
static KEYCHAIN_SERVICE_NAME: &str = "Materialize";
#[cfg(target_os = "macos")]
static OLD_KEYCHAIN_SERVICE_NAME: &str = "Materialize mz CLI";
#[cfg(target_os = "macos")]
static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> =
LazyLock::new(|| Some(Vault::Keychain.as_str()));
#[cfg(not(target_os = "macos"))]
static DEFAULT_VAULT_VALUE: LazyLock<Option<&str>> = LazyLock::new(|| Some(Vault::Inline.as_str()));
static GLOBAL_PARAMS: LazyLock<BTreeMap<&'static str, GlobalParam>> = LazyLock::new(|| {
btreemap! {
"profile" => GlobalParam {
get: |config_file| {
config_file.profile.as_deref().or(Some("default"))
},
},
"vault" => GlobalParam {
get: |config_file| {
config_file.vault.as_deref().or(*DEFAULT_VAULT_VALUE)
},
}
}
});
#[derive(Clone)]
pub struct ConfigFile {
path: PathBuf,
parsed: TomlConfigFile,
editable: Document,
}
impl ConfigFile {
pub fn default_path() -> Result<PathBuf, Error> {
let Some(mut path) = dirs::home_dir() else {
panic!("unable to discover home directory")
};
path.push(".config/materialize/mz.toml");
Ok(path)
}
pub async fn load(path: PathBuf) -> Result<ConfigFile, Error> {
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).await?;
}
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
let parsed = toml_edit::de::from_str(&buffer)?;
let editable = buffer.parse()?;
Ok(ConfigFile {
path,
parsed,
editable,
})
}
pub fn load_profile<'a>(&'a self, name: &'a str) -> Result<Profile, Error> {
match &self.parsed.profiles {
Some(profiles) => match profiles.get(name) {
None => Err(Error::ProfileMissing(name.to_string())),
Some(parsed_profile) => Ok(Profile {
name,
parsed: parsed_profile,
}),
},
None => Err(Error::ProfilesMissing),
}
}
pub async fn add_profile(&self, name: String, profile: TomlProfile) -> Result<(), Error> {
let mut editable = self.editable.clone();
let profiles = editable.entry("profiles").or_insert(toml_edit::table());
let mut new_profile = toml_edit::Table::new();
self.add_app_password(&mut new_profile, &name, profile.clone())?;
new_profile["region"] = value(profile.region.unwrap_or("aws/us-east-1".to_string()));
if let Some(admin_endpoint) = profile.admin_endpoint {
new_profile["admin-endpoint"] = value(admin_endpoint);
}
if let Some(cloud_endpoint) = profile.cloud_endpoint {
new_profile["cloud-endpoint"] = value(cloud_endpoint);
}
if let Some(vault) = profile.vault {
new_profile["vault"] = value(vault.to_string());
}
profiles[name.clone()] = toml_edit::Item::Table(new_profile);
editable["profiles"] = profiles.clone();
editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
fs::write(&self.path, editable.to_string()).await?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn add_app_password(
&self,
new_profile: &mut toml_edit::Table,
name: &str,
profile: TomlProfile,
) -> Result<(), Error> {
if Vault::Keychain == self.vault() {
let app_password = profile.app_password.ok_or(Error::AppPasswordMissing)?;
set_generic_password(KEYCHAIN_SERVICE_NAME, name, app_password.as_bytes())
.map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
} else {
new_profile["app-password"] =
value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn add_app_password(
&self,
new_profile: &mut toml_edit::Table,
_name: &str,
profile: TomlProfile,
) -> Result<(), Error> {
new_profile["app-password"] = value(profile.app_password.ok_or(Error::AppPasswordMissing)?);
Ok(())
}
pub async fn remove_profile<'a>(&self, name: &str) -> Result<(), Error> {
let mut editable = self.editable.clone();
let profiles = editable["profiles"]
.as_table_mut()
.ok_or(Error::ProfilesMissing)?;
profiles.remove(name);
fs::write(&self.path, editable.to_string()).await?;
Ok(())
}
pub fn profile(&self) -> &str {
(GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
}
pub fn vault(&self) -> &str {
(GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
}
pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
self.parsed.profiles.clone()
}
pub fn list_profile_params(
&self,
profile_name: &str,
) -> Result<Vec<(&str, Option<String>)>, Error> {
let profile = self
.parsed
.profiles
.clone()
.ok_or(Error::ProfilesMissing)?
.get(profile_name)
.ok_or(Error::ProfileMissing(self.profile().to_string()))?
.clone();
let out = vec![
("admin-endpoint", profile.admin_endpoint),
("app-password", profile.app_password),
("cloud-endpoint", profile.cloud_endpoint),
("region", profile.region),
("vault", profile.vault.map(|x| x.to_string())),
];
Ok(out)
}
pub fn get_profile_param<'a>(
&'a self,
name: &str,
profile: &'a str,
) -> Result<Option<&str>, Error> {
let profile = self.load_profile(profile)?;
let value = (PROFILE_PARAMS[name].get)(profile.parsed);
Ok(value)
}
pub async fn set_profile_param(
&self,
profile_name: &str,
name: &str,
value: Option<&str>,
) -> Result<(), Error> {
let mut editable = self.editable.clone();
match value {
None => {
let profile = editable["profiles"][profile_name]
.as_table_mut()
.ok_or(Error::ProfileMissing(name.to_string()))?;
if profile.contains_key(name) {
profile.remove(name);
}
}
Some(value) => editable["profiles"][profile_name][name] = toml_edit::value(value),
}
fs::write(&self.path, editable.to_string()).await?;
Ok(())
}
pub fn get_param(&self, name: &str) -> Result<Option<&str>, Error> {
match GLOBAL_PARAMS.get(name) {
Some(param) => Ok((param.get)(&self.parsed)),
None => panic!("unknown configuration parameter {}", name.quoted()),
}
}
pub fn list_params(&self) -> Vec<(&str, Option<&str>)> {
let mut out = vec![];
for (name, param) in &*GLOBAL_PARAMS {
out.push((*name, (param.get)(&self.parsed)));
}
out
}
pub async fn set_param(&self, name: &str, value: Option<&str>) -> Result<(), Error> {
if !GLOBAL_PARAMS.contains_key(name) {
panic!("unknown configuration parameter {}", name.quoted());
}
let mut editable = self.editable.clone();
match value {
None => {
editable.remove(name);
}
Some(value) => editable[name] = toml_edit::value(value),
}
fs::write(&self.path, editable.to_string()).await?;
Ok(())
}
}
static PROFILE_PARAMS: LazyLock<BTreeMap<&'static str, ProfileParam>> = LazyLock::new(|| {
btreemap! {
"app-password" => ProfileParam {
get: |t| t.app_password.as_deref(),
},
"region" => ProfileParam {
get: |t| t.region.as_deref(),
},
"vault" => ProfileParam {
get: |t| t.vault.clone().map(|x| x.as_str()),
},
"admin-endpoint" => ProfileParam {
get: |t| t.admin_endpoint.as_deref(),
},
"cloud-endpoint" => ProfileParam {
get: |t| t.cloud_endpoint.as_deref(),
},
}
});
pub struct Profile<'a> {
name: &'a str,
parsed: &'a TomlProfile,
}
impl Profile<'_> {
pub fn name(&self) -> &str {
self.name
}
#[cfg(target_os = "macos")]
pub fn app_password(&self, global_vault: &str) -> Result<String, Error> {
if let Some(vault) = self.vault().or(Some(global_vault)) {
if vault == Vault::Keychain {
let password = get_generic_password(KEYCHAIN_SERVICE_NAME, self.name);
match password {
Ok(generic_password) => {
let parsed_password = String::from_utf8(generic_password.to_vec());
match parsed_password {
Ok(app_password) => return Ok(app_password),
Err(err) => return Err(Error::MacOsSecurityError(err.to_string())),
}
}
Err(err) => {
if err.code() == -25300 {
let password =
get_generic_password(OLD_KEYCHAIN_SERVICE_NAME, self.name);
if let Ok(generic_password) = password {
let parsed_password = String::from_utf8(generic_password.to_vec());
match parsed_password {
Ok(app_password) => {
set_generic_password(
KEYCHAIN_SERVICE_NAME,
self.name,
app_password.as_bytes(),
)
.map_err(|e| Error::MacOsSecurityError(e.to_string()))?;
return Ok(app_password);
}
Err(err) => {
return Err(Error::MacOsSecurityError(err.to_string()))
}
}
}
}
return Err(Error::MacOsSecurityError(err.to_string()));
}
}
}
}
(PROFILE_PARAMS["app-password"].get)(self.parsed)
.map(|x| x.to_string())
.ok_or(Error::AppPasswordMissing)
}
#[cfg(not(target_os = "macos"))]
pub fn app_password(&self, _global_vault: &str) -> Result<String, Error> {
(PROFILE_PARAMS["app-password"].get)(self.parsed)
.map(|x| x.to_string())
.ok_or(Error::AppPasswordMissing)
}
pub fn region(&self) -> Option<&str> {
(PROFILE_PARAMS["region"].get)(self.parsed)
}
pub fn vault(&self) -> Option<&str> {
(PROFILE_PARAMS["vault"].get)(self.parsed)
}
pub fn admin_endpoint(&self) -> Option<&str> {
(PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
}
pub fn cloud_endpoint(&self) -> Option<&str> {
(PROFILE_PARAMS["cloud-endpoint"].get)(self.parsed)
}
}
struct ConfigParam<T> {
get: fn(&T) -> Option<&str>,
}
type GlobalParam = ConfigParam<TomlConfigFile>;
type ProfileParam = ConfigParam<TomlProfile>;
#[derive(Clone, Deserialize, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Vault {
Keychain,
Inline,
}
impl ToString for Vault {
fn to_string(&self) -> String {
match self {
Vault::Keychain => "keychain".to_string(),
Vault::Inline => "inline".to_string(),
}
}
}
impl Vault {
fn as_str(&self) -> &'static str {
match self {
Vault::Keychain => "keychain",
Vault::Inline => "inline",
}
}
}
impl FromStr for Vault {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self, crate::error::Error> {
match s.to_ascii_lowercase().as_str() {
"keychain" => Ok(Vault::Keychain),
"inline" => Ok(Vault::Inline),
_ => Err(Error::InvalidVaultError),
}
}
}
impl PartialEq<&str> for Vault {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl PartialEq<Vault> for &str {
fn eq(&self, other: &Vault) -> bool {
self == &other.as_str()
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
struct TomlConfigFile {
profile: Option<String>,
vault: Option<String>,
profiles: Option<BTreeMap<String, TomlProfile>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub struct TomlProfile {
pub app_password: Option<String>,
pub region: Option<String>,
pub vault: Option<Vault>,
pub admin_endpoint: Option<String>,
pub cloud_endpoint: Option<String>,
}