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(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        // If there is no profile assigned in the global config assign one.
157        editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
158
159        // TODO: I don't know why it creates an empty [profiles] table
160        fs::write(&self.path, editable.to_string()).await?;
161
162        Ok(())
163    }
164
165    /// Adds an app-password to the configuration file or
166    /// to the keychain if the vault is enabled.
167    #[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    /// Adds an app-password to the configuration file.
187    #[cfg(not(target_os = "macos"))]
188    pub fn add_app_password(
189        &self,
190        new_profile: &mut toml_edit::Table,
191        // Compatibility param.
192        _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    /// Removes a profile from the configuration file.
201    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    /// Retrieves the default profile
214    pub fn profile(&self) -> &str {
215        (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
216    }
217
218    /// Retrieves the default vault value
219    pub fn vault(&self) -> &str {
220        (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
221    }
222
223    /// Retrieves all the available profiles
224    pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
225        self.parsed.profiles.clone()
226    }
227
228    /// Returns a list of all the possible profile configuration values
229    pub fn list_profile_params(
230        &self,
231        profile_name: &str,
232    ) -> Result<Vec<(&str, Option<String>)>, Error> {
233        // Use the parsed profile rather than reading from the editable.
234        // If there is a missing field it is more difficult to detect.
235        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    /// Gets the value of a profile's configuration parameter.
256    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    /// Sets the value of a profile's configuration parameter.
268    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        // Update the value
277        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    /// Gets the value of a configuration parameter.
295    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    /// Lists the all configuration parameters.
303    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    /// Sets the value of a configuration parameter.
312    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
348/// Defines the profile structure inside the configuration file.
349///
350/// It is divided into two fields:
351/// * name: represents the profile name.
352/// * parsed: represents the configuration values of the profile.
353pub struct Profile<'a> {
354    name: &'a str,
355    parsed: &'a TomlProfile,
356}
357
358impl Profile<'_> {
359    /// Returns the name of the profile.
360    pub fn name(&self) -> &str {
361        self.name
362    }
363
364    /// Returns the app password in the profile configuration.
365    #[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                        // Not found error code. Check if it belongs to the old service.
381                        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                                // If there is a match, migrate the password from the old service name to the one one.
388                                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    /// Returns the app password in the profile configuration.
417    #[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    /// Returns the region in the profile configuration.
425    pub fn region(&self) -> Option<&str> {
426        (PROFILE_PARAMS["region"].get)(self.parsed)
427    }
428
429    /// Returns the vault value in the profile configuration.
430    pub fn vault(&self) -> Option<&str> {
431        (PROFILE_PARAMS["vault"].get)(self.parsed)
432    }
433
434    /// Returns the admin endpoint in the profile configuration.
435    pub fn admin_endpoint(&self) -> Option<&str> {
436        (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
437    }
438
439    /// Returns the cloud endpoint in the profile configuration.
440    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/// This structure represents the two possible
453/// values for the vault field.
454#[derive(Clone, Deserialize, Debug, Serialize)]
455#[serde(rename_all = "lowercase")]
456pub enum Vault {
457    /// Default for macOS. Stores passwords in the macOS keychain.
458    Keychain,
459    /// Default for Linux. Stores passwords in the config file.
460    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)]
516/// Describes the structure fields for a profile in the configuration file.
517pub struct TomlProfile {
518    /// The profile's unique app-password
519    pub app_password: Option<String>,
520    /// The profile's region to use by default.
521    pub region: Option<String>,
522    /// The vault value to use in MacOS.
523    pub vault: Option<Vault>,
524    /// A custom admin endpoint used for development.
525    pub admin_endpoint: Option<String>,
526    /// A custom cloud endpoint used for development.
527    pub cloud_endpoint: Option<String>,
528}