mz/
config_file.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Configuration file management.
17
18use 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/// Service name displayed to the user when using the keychain.
36/// If you ever have to change this value, make sure to update,
37/// the keychain service name in the VS Code extension.
38#[cfg(target_os = "macos")]
39static KEYCHAIN_SERVICE_NAME: &str = "Materialize";
40
41/// Old keychain name keeped for compatibility.
42/// TODO: Should be removed after > 0.2.6
43#[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/// Represents an on-disk configuration file for `mz`.
69#[derive(Clone)]
70pub struct ConfigFile {
71    path: PathBuf,
72    parsed: TomlConfigFile,
73    editable: DocumentMut,
74}
75
76impl ConfigFile {
77    /// Computes the default path for the configuration file.
78    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    /// Loads a configuration file from the specified path.
87    pub async fn load(path: PathBuf) -> Result<ConfigFile, Error> {
88        // Create the parent directory if it doesn't exist
89        if let Some(parent) = path.parent() {
90            if !parent.exists() {
91                fs::create_dir_all(parent).await?;
92            }
93        }
94
95        // Create the file if it doesn't exist
96        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    /// Loads a profile from the configuration file.
117    /// Panics if the profile is not found.
118    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    /// Adds a new profile to the config file.
132    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        // If there is no profile assigned in the global config assign one.
161        editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
162
163        // TODO: I don't know why it creates an empty [profiles] table
164        fs::write(&self.path, editable.to_string()).await?;
165
166        Ok(())
167    }
168
169    /// Adds an app-password to the configuration file or
170    /// to the keychain if the vault is enabled.
171    #[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    /// Adds an app-password to the configuration file.
191    #[cfg(not(target_os = "macos"))]
192    pub fn add_app_password(
193        &self,
194        new_profile: &mut toml_edit::Table,
195        // Compatibility param.
196        _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    /// Removes a profile from the configuration file.
205    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    /// Retrieves the default profile
218    pub fn profile(&self) -> &str {
219        (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
220    }
221
222    /// Retrieves the default vault value
223    pub fn vault(&self) -> &str {
224        (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
225    }
226
227    /// Retrieves all the available profiles
228    pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
229        self.parsed.profiles.clone()
230    }
231
232    /// Returns a list of all the possible profile configuration values
233    pub fn list_profile_params(
234        &self,
235        profile_name: &str,
236    ) -> Result<Vec<(&str, Option<String>)>, Error> {
237        // Use the parsed profile rather than reading from the editable.
238        // If there is a missing field it is more difficult to detect.
239        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    /// Gets the value of a profile's configuration parameter.
260    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    /// Sets the value of a profile's configuration parameter.
272    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        // Update the value
281        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    /// Gets the value of a configuration parameter.
299    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    /// Lists the all configuration parameters.
307    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    /// Sets the value of a configuration parameter.
316    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
352/// Defines the profile structure inside the configuration file.
353///
354/// It is divided into two fields:
355/// * name: represents the profile name.
356/// * parsed: represents the configuration values of the profile.
357pub struct Profile<'a> {
358    name: &'a str,
359    parsed: &'a TomlProfile,
360}
361
362impl Profile<'_> {
363    /// Returns the name of the profile.
364    pub fn name(&self) -> &str {
365        self.name
366    }
367
368    /// Returns the app password in the profile configuration.
369    #[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                        // Not found error code. Check if it belongs to the old service.
385                        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                                // If there is a match, migrate the password from the old service name to the one one.
392                                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    /// Returns the app password in the profile configuration.
421    #[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    /// Returns the region in the profile configuration.
429    pub fn region(&self) -> Option<&str> {
430        (PROFILE_PARAMS["region"].get)(self.parsed)
431    }
432
433    /// Returns the vault value in the profile configuration.
434    pub fn vault(&self) -> Option<&str> {
435        (PROFILE_PARAMS["vault"].get)(self.parsed)
436    }
437
438    /// Returns the admin endpoint in the profile configuration.
439    pub fn admin_endpoint(&self) -> Option<&str> {
440        (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
441    }
442
443    /// Returns the cloud endpoint in the profile configuration.
444    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/// This structure represents the two possible
457/// values for the vault field.
458#[derive(Clone, Deserialize, Debug, Serialize)]
459#[serde(rename_all = "lowercase")]
460pub enum Vault {
461    /// Default for macOS. Stores passwords in the macOS keychain.
462    Keychain,
463    /// Default for Linux. Stores passwords in the config file.
464    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)]
520/// Describes the structure fields for a profile in the configuration file.
521pub struct TomlProfile {
522    /// The profile's unique app-password
523    pub app_password: Option<String>,
524    /// The profile's region to use by default.
525    pub region: Option<String>,
526    /// The vault value to use in MacOS.
527    pub vault: Option<Vault>,
528    /// A custom admin endpoint used for development.
529    pub admin_endpoint: Option<String>,
530    /// A custom cloud endpoint used for development.
531    pub cloud_endpoint: Option<String>,
532}