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    pub fn available_profiles(&self) -> Vec<String> {
96        self.profiles.keys().cloned().collect()
97    }
98}
99
100/// Impl block for credentials_loader related functions.
101#[bon]
102impl Credentials {
103    /// Attempts to load the remote.it credentials from the user's home directory.
104    /// The default location is `~/.remoteit/credentials`.
105    ///
106    /// # Errors
107    /// * [`CredentialsLoaderError::HomeDirNotFound`], when the [`dirs`] create cannot find the user's home directory.
108    /// * [`CredentialsLoaderError::CouldNotReadCredentials`], when the credentials file could not be parsed by the [`config`] crate.
109    ///
110    /// # Example
111    /// You can load credentials from the default path (`~/.remoteit/credentials` on Unix-like), or provide a custom path.
112    /// ```
113    /// # use remoteit_api::Credentials;
114    /// let credentials_file = Credentials::load_from_disk()
115    ///     .custom_credentials_path("path/to/file") // Optional
116    ///     .call();
117    /// ```
118    /// You can also pass a PathBuf, or anything that implements [`Into<PathBuf>`]
119    /// ```
120    /// # use std::path::PathBuf;
121    /// # use remoteit_api::Credentials;
122    /// let credentials_file = Credentials::load_from_disk()
123    ///     .custom_credentials_path(PathBuf::from("path/to/file")) // Optional
124    ///     .call();
125    /// ```
126    #[builder]
127    pub fn load_from_disk(
128        custom_credentials_path: Option<PathBuf>,
129    ) -> Result<CredentialProfiles, CredentialsLoaderError> {
130        let credentials_path = custom_credentials_path.unwrap_or(
131            dirs::home_dir()
132                .ok_or(CredentialsLoaderError::HomeDirNotFound)?
133                .join(".remoteit")
134                .join("credentials"),
135        );
136
137        let profiles: CredentialProfiles = config::Config::builder()
138            .add_source(config::File::new(
139                credentials_path
140                    .to_str()
141                    .expect("It is highly unlikely, that there would be a "),
142                config::FileFormat::Ini,
143            ))
144            .build()?
145            .try_deserialize()?;
146
147        Ok(profiles)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use crate::credentials::Credentials;
154    use crate::CredentialsLoaderError;
155    use std::io::Write;
156
157    #[test]
158    fn test_load_from_disk_empty() {
159        let file = tempfile::NamedTempFile::new().unwrap();
160
161        let credentials = Credentials::load_from_disk()
162            .custom_credentials_path(file.path().to_path_buf())
163            .call()
164            .unwrap();
165
166        assert!(credentials.is_empty());
167    }
168
169    #[test]
170    fn test_load_from_disk_one() {
171        let credentials = r"
172            [default]
173            R3_ACCESS_KEY_ID=foo
174            R3_SECRET_ACCESS_KEY=YmFy
175        ";
176
177        let mut file = tempfile::NamedTempFile::new().unwrap();
178        file.write_all(credentials.as_bytes()).unwrap();
179
180        let mut credentials = Credentials::load_from_disk()
181            .custom_credentials_path(file.path().to_path_buf())
182            .call()
183            .unwrap();
184
185        assert_eq!(credentials.len(), 1);
186        let credentials = credentials.take_profile("default").unwrap().unwrap();
187        assert_eq!(credentials.r3_access_key_id, "foo");
188        assert_eq!(credentials.r3_secret_access_key, "YmFy");
189    }
190
191    #[test]
192    fn test_load_from_disk_two() {
193        let credentials = r"
194            [default]
195            R3_ACCESS_KEY_ID=foo
196            R3_SECRET_ACCESS_KEY=YmFy
197
198            [other]
199            R3_ACCESS_KEY_ID=baz
200            R3_SECRET_ACCESS_KEY=YmFy
201        ";
202
203        let mut file = tempfile::NamedTempFile::new().unwrap();
204        file.write_all(credentials.as_bytes()).unwrap();
205
206        let mut credentials = Credentials::load_from_disk()
207            .custom_credentials_path(file.path().to_path_buf())
208            .call()
209            .unwrap();
210
211        assert_eq!(credentials.len(), 2);
212        let profile = credentials.take_profile("default").unwrap().unwrap();
213        assert_eq!(profile.r3_access_key_id, "foo");
214        assert_eq!(profile.r3_secret_access_key, "YmFy");
215        let profile = credentials.take_profile("other").unwrap().unwrap();
216        assert_eq!(profile.r3_access_key_id, "baz");
217        assert_eq!(profile.r3_secret_access_key, "YmFy");
218    }
219
220    #[test]
221    fn test_load_from_disk_invalid_base64() {
222        let credentials = r"
223            [default]
224            R3_ACCESS_KEY_ID=foo
225            R3_SECRET_ACCESS_KEY=bar
226        ";
227        let mut file = tempfile::NamedTempFile::new().unwrap();
228        file.write_all(credentials.as_bytes()).unwrap();
229
230        let mut credentials = Credentials::load_from_disk()
231            .custom_credentials_path(file.path().to_path_buf())
232            .call()
233            .unwrap();
234
235        let result = credentials.take_profile("default");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn test_load_from_disk_invalid_file() {
241        let credentials = r"
242            foobar
243        ";
244
245        let mut file = tempfile::NamedTempFile::new().unwrap();
246        file.write_all(credentials.as_bytes()).unwrap();
247
248        let result = Credentials::load_from_disk()
249            .custom_credentials_path(file.path().to_path_buf())
250            .call();
251
252        assert!(result.is_err());
253        assert!(matches!(
254            result.unwrap_err(),
255            CredentialsLoaderError::CredentialsParse(_)
256        ));
257    }
258
259    #[test]
260    fn test_get_available_profiles() {
261        let credentials = r"
262            [default]
263            R3_ACCESS_KEY_ID=foo
264            R3_SECRET_ACCESS_KEY=YmFy
265
266            [other]
267            R3_ACCESS_KEY_ID=baz
268            R3_SECRET_ACCESS_KEY=YmFy
269        ";
270
271        let mut file = tempfile::NamedTempFile::new().unwrap();
272        file.write_all(credentials.as_bytes()).unwrap();
273
274        let credentials = Credentials::load_from_disk()
275            .custom_credentials_path(file.path().to_path_buf())
276            .call()
277            .unwrap();
278
279        let profiles = credentials.available_profiles();
280        assert_eq!(profiles.len(), 2);
281        assert!(profiles.contains(&"default".to_string()));
282        assert!(profiles.contains(&"other".to_string()));
283    }
284}