remoteit_api/
credentials_loader.rs

1//! Contains items related to loading credentials from disk.
2//!
3//! Please see [`Credentials`] for more.
4
5use crate::credentials::Credentials;
6use bon::bon;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Errors that can occur during the loading of credentials from disk.
11#[allow(missing_docs)]
12#[derive(thiserror::Error, Debug)]
13pub enum CredentialsLoaderError {
14    #[error(
15        "The user's home directory could not be found. Please refer to the `dirs` crate for more information."
16    )]
17    HomeDirNotFound,
18    #[error("The credentials file could not be loaded: {0}")]
19    CouldNotReadCredentials(#[from] std::io::Error),
20    #[error("The credentials file could not be parsed: {0}")]
21    CredentialsParse(#[from] config::ConfigError),
22}
23
24/// This is how the credentials are saved in the file.
25/// Unverified, because the `r3_secret_access_key` must be valid base64, but is not validated while parsing the file.
26#[derive(
27    Debug, Clone, PartialOrd, PartialEq, Eq, Ord, Hash, serde::Deserialize, serde::Serialize,
28)]
29pub(crate) struct UnverifiedCredentials {
30    pub(crate) r3_access_key_id: String,
31    pub(crate) r3_secret_access_key: String,
32}
33
34/// A struct representing the remote.it credentials file.
35///
36/// The credentials file can have multiple profiles, each with its own access key ID and secret access key.
37///
38/// The secret access keys of the profiles within this struct are base64 encoded.
39/// At this point they are unverified, which is why the inner [`HashMap`] is private.
40/// The secret key of the profile you want will be verified, when the profile is retrieved using one of:
41/// - [`CredentialProfiles::take_profile`]
42/// - [`CredentialProfiles::profile`]
43#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
44pub struct CredentialProfiles {
45    #[serde(flatten)]
46    pub(crate) profiles: HashMap<String, UnverifiedCredentials>,
47}
48
49impl CredentialProfiles {
50    /// Takes the profile with the given name out of the inner [`HashMap`], validated the secret access key and returns it.
51    /// You can only take a profile once, after that it is removed from the inner [`HashMap`].
52    ///
53    /// # Returns
54    /// - [`None`] if the profile with the given name does not exist.
55    /// - [`Some`] containing the [`Credentials`] with the given name, if the profile exists.
56    ///
57    /// # Errors
58    /// - [`base64::DecodeError`] if the secret access key of the profile with the given name is not base64 encoded.
59    pub fn take_profile(
60        &mut self,
61        profile_name: &str,
62    ) -> Result<Option<Credentials>, base64::DecodeError> {
63        let maybe_unverified_credentials = self.profiles.remove(profile_name);
64
65        let Some(unverified_credentials) = maybe_unverified_credentials else {
66            return Ok(None);
67        };
68
69        Credentials::builder()
70            .r3_access_key_id(unverified_credentials.r3_access_key_id)
71            .r3_secret_access_key(unverified_credentials.r3_secret_access_key)
72            .build()
73            .map(Some)
74    }
75
76    /// # Returns
77    /// The number of profiles in the inner [`HashMap`].
78    #[must_use]
79    pub fn len(&self) -> usize {
80        self.profiles.len()
81    }
82
83    /// # Returns
84    /// - [`true`] if there are no profiles in the inner [`HashMap`].
85    /// - [`false`] if there is at least one profile in the inner [`HashMap`].
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.profiles.is_empty()
89    }
90
91    /// # Returns
92    /// A list of Strings containing the names of the profiles in the inner [`HashMap`].
93    /// The order of the profiles is not guaranteed.
94    ///
95    /// # Warning
96    /// This doesn't mean that the profiles are valid (valid base64 encoded secret), only that they exist.
97    #[must_use]
98    pub fn available_profiles(&self) -> Vec<String> {
99        self.profiles.keys().cloned().collect()
100    }
101}
102
103/// Impl block for credentials_loader related functions.
104#[bon]
105impl Credentials {
106    /// Attempts to load the remote.it credentials from the user's home directory.
107    /// The default location is `~/.remoteit/credentials`.
108    ///
109    /// # Errors
110    /// * [`CredentialsLoaderError::HomeDirNotFound`], when the [`dirs`] create cannot find the user's home directory.
111    /// * [`CredentialsLoaderError::CouldNotReadCredentials`], when the credentials file could not be parsed by the [`config`] crate.
112    ///
113    /// # Example
114    /// You can load credentials from the default path (`~/.remoteit/credentials` on Unix-like), or provide a custom path.
115    /// ```
116    /// # use std::path::PathBuf;
117    /// # use remoteit_api::Credentials;
118    /// let credentials_file = Credentials::load_from_disk()
119    ///     .custom_credentials_path(PathBuf::from("path/to/file")) // Optional
120    ///     .call();
121    /// ```
122    #[builder]
123    pub fn load_from_disk(
124        #[builder(into)] custom_credentials_path: Option<PathBuf>,
125    ) -> Result<CredentialProfiles, CredentialsLoaderError> {
126        let credentials_path = custom_credentials_path.unwrap_or(
127            dirs::home_dir()
128                .ok_or(CredentialsLoaderError::HomeDirNotFound)?
129                .join(".remoteit")
130                .join("credentials"),
131        );
132
133        let profiles: CredentialProfiles = config::Config::builder()
134            .add_source(config::File::new(
135                credentials_path
136                    .to_str()
137                    .expect("It is highly unlikely, that there would be a "),
138                config::FileFormat::Ini,
139            ))
140            .build()?
141            .try_deserialize()?;
142
143        Ok(profiles)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use crate::CredentialsLoaderError;
150    use crate::credentials::Credentials;
151    use std::io::Write;
152
153    #[test]
154    fn test_load_from_disk_empty() {
155        let file = tempfile::NamedTempFile::new().unwrap();
156
157        let credentials = Credentials::load_from_disk()
158            .custom_credentials_path(file.path().to_path_buf())
159            .call()
160            .unwrap();
161
162        assert!(credentials.is_empty());
163    }
164
165    #[test]
166    fn test_load_from_disk_one() {
167        let credentials = r"
168            [default]
169            R3_ACCESS_KEY_ID=foo
170            R3_SECRET_ACCESS_KEY=YmFy
171        ";
172
173        let mut file = tempfile::NamedTempFile::new().unwrap();
174        file.write_all(credentials.as_bytes()).unwrap();
175
176        let mut credentials = Credentials::load_from_disk()
177            .custom_credentials_path(file.path().to_path_buf())
178            .call()
179            .unwrap();
180
181        assert_eq!(credentials.len(), 1);
182        let credentials = credentials.take_profile("default").unwrap().unwrap();
183        assert_eq!(credentials.access_key_id(), "foo");
184        assert_eq!(credentials.secret_access_key(), "YmFy");
185    }
186
187    #[test]
188    fn test_load_from_disk_two() {
189        let credentials = r"
190            [default]
191            R3_ACCESS_KEY_ID=foo
192            R3_SECRET_ACCESS_KEY=YmFy
193
194            [other]
195            R3_ACCESS_KEY_ID=baz
196            R3_SECRET_ACCESS_KEY=YmFy
197        ";
198
199        let mut file = tempfile::NamedTempFile::new().unwrap();
200        file.write_all(credentials.as_bytes()).unwrap();
201
202        let mut credentials = Credentials::load_from_disk()
203            .custom_credentials_path(file.path().to_path_buf())
204            .call()
205            .unwrap();
206
207        assert_eq!(credentials.len(), 2);
208        let profile = credentials.take_profile("default").unwrap().unwrap();
209        assert_eq!(profile.access_key_id(), "foo");
210        assert_eq!(profile.secret_access_key(), "YmFy");
211        let profile = credentials.take_profile("other").unwrap().unwrap();
212        assert_eq!(profile.access_key_id(), "baz");
213        assert_eq!(profile.secret_access_key(), "YmFy");
214    }
215
216    #[test]
217    fn test_load_from_disk_invalid_base64() {
218        let credentials = r"
219            [default]
220            R3_ACCESS_KEY_ID=foo
221            R3_SECRET_ACCESS_KEY=bar
222        ";
223        let mut file = tempfile::NamedTempFile::new().unwrap();
224        file.write_all(credentials.as_bytes()).unwrap();
225
226        let mut credentials = Credentials::load_from_disk()
227            .custom_credentials_path(file.path().to_path_buf())
228            .call()
229            .unwrap();
230
231        let result = credentials.take_profile("default");
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn test_load_from_disk_invalid_file() {
237        let credentials = r"
238            foobar
239        ";
240
241        let mut file = tempfile::NamedTempFile::new().unwrap();
242        file.write_all(credentials.as_bytes()).unwrap();
243
244        let result = Credentials::load_from_disk()
245            .custom_credentials_path(file.path().to_path_buf())
246            .call();
247
248        assert!(result.is_err());
249        assert!(matches!(
250            result.unwrap_err(),
251            CredentialsLoaderError::CredentialsParse(_)
252        ));
253    }
254
255    #[test]
256    fn test_get_available_profiles() {
257        let credentials = r"
258            [default]
259            R3_ACCESS_KEY_ID=foo
260            R3_SECRET_ACCESS_KEY=YmFy
261
262            [other]
263            R3_ACCESS_KEY_ID=baz
264            R3_SECRET_ACCESS_KEY=YmFy
265        ";
266
267        let mut file = tempfile::NamedTempFile::new().unwrap();
268        file.write_all(credentials.as_bytes()).unwrap();
269
270        let credentials = Credentials::load_from_disk()
271            .custom_credentials_path(file.path().to_path_buf())
272            .call()
273            .unwrap();
274
275        let profiles = credentials.available_profiles();
276        assert_eq!(profiles.len(), 2);
277        assert!(profiles.contains(&"default".to_string()));
278        assert!(profiles.contains(&"other".to_string()));
279    }
280}