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