Skip to main content

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::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/// Service name displayed to the user when using the keychain.
34/// If you ever have to change this value, make sure to update,
35/// the keychain service name in the VS Code extension.
36#[cfg(target_os = "macos")]
37static KEYCHAIN_SERVICE_NAME: &str = "Materialize";
38
39/// Old keychain name keeped for compatibility.
40/// TODO: Should be removed after > 0.2.6
41#[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/// Represents an on-disk configuration file for `mz`.
67#[derive(Clone)]
68pub struct ConfigFile {
69    path: PathBuf,
70    parsed: TomlConfigFile,
71    editable: DocumentMut,
72}
73
74impl ConfigFile {
75    /// Computes the default path for the configuration file.
76    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    /// Loads a configuration file from the specified path.
85    ///
86    /// A missing file loads as an empty configuration. Loading never creates
87    /// the file or its parent directory, and never requires write access, so
88    /// commands that only read the configuration work when `mz.toml` lives on
89    /// a read-only mount. The first mutation creates whatever is missing.
90    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    /// Writes `contents` to the configuration file, creating the parent
108    /// directory if it doesn't exist.
109    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    /// Errors unless the configuration file can be written, creating the file
119    /// and its parent directory if they are missing.
120    ///
121    /// Commands that cause external side effects before mutating the
122    /// configuration, such as creating an app password or writing to the
123    /// keychain, must call this beforehand. Otherwise an unwritable
124    /// configuration file fails the command only after those side effects have
125    /// happened, leaving them unrecorded.
126    ///
127    /// Creating what is missing is what makes the check conclusive: whether a
128    /// missing file can be written depends on the parent hierarchy, which no
129    /// amount of probing establishes as reliably as performing the creation
130    /// that the later write needs anyway.
131    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    /// Loads a profile from the configuration file.
152    /// Panics if the profile is not found.
153    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    /// Adds a new profile to the config file.
167    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        // If there is no profile assigned in the global config assign one.
196        editable["profile"] = editable.entry("profile").or_insert(value(name)).clone();
197
198        // TODO: I don't know why it creates an empty [profiles] table
199        self.write(editable.to_string()).await?;
200
201        Ok(())
202    }
203
204    /// Adds an app-password to the configuration file or
205    /// to the keychain if the vault is enabled.
206    #[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    /// Adds an app-password to the configuration file.
226    #[cfg(not(target_os = "macos"))]
227    pub fn add_app_password(
228        &self,
229        new_profile: &mut toml_edit::Table,
230        // Compatibility param.
231        _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    /// Removes a profile from the configuration file.
240    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    /// Retrieves the default profile
253    pub fn profile(&self) -> &str {
254        (GLOBAL_PARAMS["profile"].get)(&self.parsed).unwrap()
255    }
256
257    /// Retrieves the default vault value
258    pub fn vault(&self) -> &str {
259        (GLOBAL_PARAMS["vault"].get)(&self.parsed).unwrap()
260    }
261
262    /// Retrieves all the available profiles
263    pub fn profiles(&self) -> Option<BTreeMap<String, TomlProfile>> {
264        self.parsed.profiles.clone()
265    }
266
267    /// Returns a list of all the possible profile configuration values
268    pub fn list_profile_params(
269        &self,
270        profile_name: &str,
271    ) -> Result<Vec<(&str, Option<String>)>, Error> {
272        // Use the parsed profile rather than reading from the editable.
273        // If there is a missing field it is more difficult to detect.
274        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    /// Gets the value of a profile's configuration parameter.
295    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    /// Sets the value of a profile's configuration parameter.
307    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        // Update the value
316        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    /// Gets the value of a configuration parameter.
334    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    /// Lists the all configuration parameters.
342    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    /// Sets the value of a configuration parameter.
351    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
387/// Defines the profile structure inside the configuration file.
388///
389/// It is divided into two fields:
390/// * name: represents the profile name.
391/// * parsed: represents the configuration values of the profile.
392pub struct Profile<'a> {
393    name: &'a str,
394    parsed: &'a TomlProfile,
395}
396
397impl Profile<'_> {
398    /// Returns the name of the profile.
399    pub fn name(&self) -> &str {
400        self.name
401    }
402
403    /// Returns the app password in the profile configuration.
404    #[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                        // Not found error code. Check if it belongs to the old service.
420                        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                                // If there is a match, migrate the password from the old service name to the one one.
427                                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    /// Returns the app password in the profile configuration.
456    #[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    /// Returns the region in the profile configuration.
464    pub fn region(&self) -> Option<&str> {
465        (PROFILE_PARAMS["region"].get)(self.parsed)
466    }
467
468    /// Returns the vault value in the profile configuration.
469    pub fn vault(&self) -> Option<&str> {
470        (PROFILE_PARAMS["vault"].get)(self.parsed)
471    }
472
473    /// Returns the admin endpoint in the profile configuration.
474    pub fn admin_endpoint(&self) -> Option<&str> {
475        (PROFILE_PARAMS["admin-endpoint"].get)(self.parsed)
476    }
477
478    /// Returns the cloud endpoint in the profile configuration.
479    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/// This structure represents the two possible
492/// values for the vault field.
493#[derive(Clone, Deserialize, Debug, Serialize)]
494#[serde(rename_all = "lowercase")]
495pub enum Vault {
496    /// Default for macOS. Stores passwords in the macOS keychain.
497    Keychain,
498    /// Default for Linux. Stores passwords in the config file.
499    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)]
555/// Describes the structure fields for a profile in the configuration file.
556pub struct TomlProfile {
557    /// The profile's unique app-password
558    pub app_password: Option<String>,
559    /// The profile's region to use by default.
560    pub region: Option<String>,
561    /// The vault value to use in MacOS.
562    pub vault: Option<Vault>,
563    /// A custom admin endpoint used for development.
564    pub admin_endpoint: Option<String>,
565    /// A custom cloud endpoint used for development.
566    pub cloud_endpoint: Option<String>,
567}
568
569#[cfg(test)]
570mod tests {
571    use tempfile::TempDir;
572
573    use super::*;
574
575    /// Returns whether the file system enforces `path`'s permission bits for
576    /// this process, which is not the case when running as root.
577    fn permissions_are_enforced(path: &PathBuf) -> bool {
578        std::fs::OpenOptions::new().write(true).open(path).is_err()
579    }
580
581    /// Makes `path` read-only and returns whether the file system enforces
582    /// that for this process, which is not the case when running as root.
583    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    /// Restores write access so that the temporary directory can be cleaned up.
599    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)] // unsupported operation: can't call foreign function `mkdir`
608    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        // Loading must not create anything on disk.
616        assert!(!path.exists());
617        assert!(!path.parent().unwrap().exists());
618    }
619
620    #[mz_ore::test(tokio::test)]
621    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `mkdir`
622    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)] // unsupported operation: can't call foreign function `mkdir`
640    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        // A missing file is only writable if its parent hierarchy accepts it.
649        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)] // unsupported operation: can't call foreign function `mkdir`
658    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        // The writability check creates what the later write needs.
665        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}