aws_runtime/env_config/
source.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Code for handling in-memory sources of profile data
7
8use super::error::{CouldNotReadConfigFile, EnvConfigFileLoadError};
9use crate::env_config::file::{EnvConfigFile, EnvConfigFileKind, EnvConfigFiles};
10use crate::fs_util::{home_dir, Os};
11use aws_smithy_types::error::display::DisplayErrorContext;
12use aws_types::os_shim_internal;
13use std::borrow::Cow;
14use std::io::ErrorKind;
15use std::path::{Component, Path, PathBuf};
16use std::sync::Arc;
17use tracing::{warn, Instrument};
18const HOME_EXPANSION_FAILURE_WARNING: &str =
19    "home directory expansion was requested (via `~` character) for the profile \
20     config file path, but no home directory could be determined";
21
22#[derive(Debug)]
23/// In-memory source of profile data
24pub struct Source {
25    /// Profile file sources
26    pub(crate) files: Vec<File>,
27
28    /// Profile to use
29    ///
30    /// Overridden via `$AWS_PROFILE`, defaults to `default`
31    pub profile: Cow<'static, str>,
32}
33
34#[derive(Debug)]
35/// In-memory configuration file
36pub struct File {
37    pub(crate) kind: EnvConfigFileKind,
38    pub(crate) path: Option<String>,
39    pub(crate) contents: String,
40}
41
42/// Load a [`Source`] from a given environment and filesystem.
43pub async fn load(
44    proc_env: &os_shim_internal::Env,
45    fs: &os_shim_internal::Fs,
46    profile_files: &EnvConfigFiles,
47) -> Result<Source, EnvConfigFileLoadError> {
48    let home = home_dir(proc_env, Os::real());
49
50    let mut files = Vec::new();
51    for file in &profile_files.files {
52        let file = load_config_file(file, &home, fs, proc_env)
53            .instrument(tracing::debug_span!("load_config_file", file = ?file))
54            .await?;
55        files.push(file);
56    }
57
58    Ok(Source {
59        files,
60        profile: proc_env
61            .get("AWS_PROFILE")
62            .map(Cow::Owned)
63            .unwrap_or(Cow::Borrowed("default")),
64    })
65}
66
67fn file_contents_to_string(path: &Path, contents: Vec<u8>) -> String {
68    // if the file is not valid utf-8, log a warning and use an empty file instead
69    match String::from_utf8(contents) {
70        Ok(contents) => contents,
71        Err(e) => {
72            tracing::warn!(path = ?path, error = %DisplayErrorContext(&e), "config file did not contain utf-8 encoded data");
73            Default::default()
74        }
75    }
76}
77
78/// Loads an AWS Config file
79///
80/// Both the default & the overriding patterns may contain `~/` which MUST be expanded to the users
81/// home directory in a platform-aware way (see [`expand_home`]).
82///
83/// Arguments:
84/// * `kind`: The type of config file to load
85/// * `home_directory`: Home directory to use during home directory expansion
86/// * `fs`: Filesystem abstraction
87/// * `environment`: Process environment abstraction
88async fn load_config_file(
89    source: &EnvConfigFile,
90    home_directory: &Option<String>,
91    fs: &os_shim_internal::Fs,
92    environment: &os_shim_internal::Env,
93) -> Result<File, EnvConfigFileLoadError> {
94    let (path, kind, contents) = match source {
95        EnvConfigFile::Default(kind) => {
96            let (path_is_default, path) = environment
97                .get(kind.override_environment_variable())
98                .map(|p| (false, Cow::Owned(p)))
99                .ok()
100                .unwrap_or_else(|| (true, kind.default_path().into()));
101            let expanded = expand_home(path.as_ref(), path_is_default, home_directory);
102            if path != expanded.to_string_lossy() {
103                tracing::debug!(before = ?path, after = ?expanded, "home directory expanded");
104            }
105            // read the data at the specified path
106            // if the path does not exist, log a warning but pretend it was actually an empty file
107            let data = match fs.read_to_end(&expanded).await {
108                Ok(data) => data,
109                Err(e) => {
110                    // Important: The default config/credentials files MUST NOT return an error
111                    match e.kind() {
112                        ErrorKind::NotFound if path == kind.default_path() => {
113                            tracing::debug!(path = %path, "config file not found")
114                        }
115                        ErrorKind::NotFound if path != kind.default_path() => {
116                            // in the case where the user overrode the path with an environment variable,
117                            // log more loudly than the case where the default path was missing
118                            tracing::warn!(path = %path, env = %kind.override_environment_variable(), "config file overridden via environment variable not found")
119                        }
120                        _other => {
121                            tracing::warn!(path = %path, error = %DisplayErrorContext(&e), "failed to read config file")
122                        }
123                    };
124                    Default::default()
125                }
126            };
127            let contents = file_contents_to_string(&expanded, data);
128            (Some(Cow::Owned(expanded)), kind, contents)
129        }
130        EnvConfigFile::FilePath { kind, path } => {
131            let data = match fs.read_to_end(&path).await {
132                Ok(data) => data,
133                Err(e) => {
134                    return Err(EnvConfigFileLoadError::CouldNotReadFile(
135                        CouldNotReadConfigFile {
136                            path: path.clone(),
137                            cause: Arc::new(e),
138                        },
139                    ))
140                }
141            };
142            (
143                Some(Cow::Borrowed(path)),
144                kind,
145                file_contents_to_string(path, data),
146            )
147        }
148        EnvConfigFile::FileContents { kind, contents } => (None, kind, contents.clone()),
149    };
150    tracing::debug!(path = ?path, size = ?contents.len(), "config file loaded");
151    Ok(File {
152        kind: *kind,
153        // lossy is OK here, the name of this file is just for debugging purposes
154        path: path.map(|p| p.to_string_lossy().into()),
155        contents,
156    })
157}
158
159fn expand_home(
160    path: impl AsRef<Path>,
161    path_is_default: bool,
162    home_dir: &Option<String>,
163) -> PathBuf {
164    let path = path.as_ref();
165    let mut components = path.components();
166    let start = components.next();
167    match start {
168        None => path.into(), // empty path,
169        Some(Component::Normal(s)) if s == "~" => {
170            // do homedir replacement
171            let path = match home_dir {
172                Some(dir) => {
173                    tracing::debug!(home = ?dir, path = ?path, "performing home directory substitution");
174                    dir.clone()
175                }
176                None => {
177                    // Only log a warning if the path was explicitly set by the customer.
178                    if !path_is_default {
179                        warn!(HOME_EXPANSION_FAILURE_WARNING);
180                    }
181                    // if we can't determine the home directory, just leave it as `~`
182                    "~".into()
183                }
184            };
185            let mut path: PathBuf = path.into();
186            // rewrite the path using system-specific path separators
187            for component in components {
188                path.push(component);
189            }
190            path
191        }
192        // Finally, handle the case where it doesn't begin with some version of `~/`:
193        // NOTE: in this case we aren't performing path rewriting. This is correct because
194        // this path comes from an environment variable on the target
195        // platform, so in that case, the separators should already be correct.
196        _other => path.into(),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use crate::env_config::error::EnvConfigFileLoadError;
203    use crate::env_config::file::{EnvConfigFile, EnvConfigFileKind, EnvConfigFiles};
204    use crate::env_config::source::{
205        expand_home, load, load_config_file, HOME_EXPANSION_FAILURE_WARNING,
206    };
207    use aws_types::os_shim_internal::{Env, Fs};
208    use futures_util::future::FutureExt;
209    use serde::Deserialize;
210    use std::collections::HashMap;
211    use std::error::Error;
212    use std::fs;
213    use tracing_test::traced_test;
214
215    #[test]
216    fn only_expand_home_prefix() {
217        // ~ is only expanded as a single component (currently)
218        let path = "~aws/config";
219        assert_eq!(
220            expand_home(path, false, &None).to_str().unwrap(),
221            "~aws/config"
222        );
223    }
224
225    #[derive(Deserialize, Debug)]
226    #[serde(rename_all = "camelCase")]
227    struct SourceTests {
228        tests: Vec<TestCase>,
229    }
230
231    #[derive(Deserialize, Debug)]
232    #[serde(rename_all = "camelCase")]
233    struct TestCase {
234        name: String,
235        environment: HashMap<String, String>,
236        platform: String,
237        profile: Option<String>,
238        config_location: String,
239        credentials_location: String,
240    }
241
242    /// Run all tests from file-location-tests.json
243    #[test]
244    fn run_tests() -> Result<(), Box<dyn Error>> {
245        let tests = fs::read_to_string("test-data/file-location-tests.json")?;
246        let tests: SourceTests = serde_json::from_str(&tests)?;
247        for (i, test) in tests.tests.into_iter().enumerate() {
248            eprintln!("test: {}", i);
249            check(test)
250                .now_or_never()
251                .expect("these futures should never poll");
252        }
253        Ok(())
254    }
255
256    #[traced_test]
257    #[test]
258    fn logs_produced_default() {
259        let env = Env::from_slice(&[("HOME", "/user/name")]);
260        let mut fs = HashMap::new();
261        fs.insert(
262            "/user/name/.aws/config".to_string(),
263            "[default]\nregion = us-east-1",
264        );
265
266        let fs = Fs::from_map(fs);
267
268        let _src = load(&env, &fs, &Default::default()).now_or_never();
269        assert!(logs_contain("config file loaded"));
270        assert!(logs_contain("performing home directory substitution"));
271    }
272
273    #[traced_test]
274    #[test]
275    fn load_config_file_should_not_emit_warning_when_path_not_explicitly_set() {
276        let env = Env::from_slice(&[]);
277        let fs = Fs::from_slice(&[]);
278
279        let _src = load_config_file(
280            &EnvConfigFile::Default(EnvConfigFileKind::Config),
281            &None,
282            &fs,
283            &env,
284        )
285        .now_or_never();
286        assert!(!logs_contain(HOME_EXPANSION_FAILURE_WARNING));
287    }
288
289    #[traced_test]
290    #[test]
291    fn load_config_file_should_emit_warning_when_path_explicitly_set() {
292        let env = Env::from_slice(&[("AWS_CONFIG_FILE", "~/some/path")]);
293        let fs = Fs::from_slice(&[]);
294
295        let _src = load_config_file(
296            &EnvConfigFile::Default(EnvConfigFileKind::Config),
297            &None,
298            &fs,
299            &env,
300        )
301        .now_or_never();
302        assert!(logs_contain(HOME_EXPANSION_FAILURE_WARNING));
303    }
304
305    async fn check(test_case: TestCase) {
306        let fs = Fs::real();
307        let env = Env::from(test_case.environment);
308        let platform_matches = (cfg!(windows) && test_case.platform == "windows")
309            || (!cfg!(windows) && test_case.platform != "windows");
310        if platform_matches {
311            let source = load(&env, &fs, &Default::default()).await.unwrap();
312            if let Some(expected_profile) = test_case.profile {
313                assert_eq!(source.profile, expected_profile, "{}", &test_case.name);
314            }
315            assert_eq!(
316                source.files[0].path,
317                Some(test_case.config_location),
318                "{}",
319                &test_case.name
320            );
321            assert_eq!(
322                source.files[1].path,
323                Some(test_case.credentials_location),
324                "{}",
325                &test_case.name
326            )
327        } else {
328            println!(
329                "NOTE: ignoring test case for {} which does not apply to our platform: \n  {}",
330                &test_case.platform, &test_case.name
331            )
332        }
333    }
334
335    #[test]
336    #[cfg_attr(windows, ignore)]
337    fn test_expand_home() {
338        let path = "~/.aws/config";
339        assert_eq!(
340            expand_home(path, false, &Some("/user/foo".to_string()))
341                .to_str()
342                .unwrap(),
343            "/user/foo/.aws/config"
344        );
345    }
346
347    #[test]
348    fn expand_home_no_home() {
349        // there is an edge case around expansion when no home directory exists
350        // if no home directory can be determined, leave the path as is
351        if !cfg!(windows) {
352            assert_eq!(
353                expand_home("~/config", false, &None).to_str().unwrap(),
354                "~/config"
355            )
356        } else {
357            assert_eq!(
358                expand_home("~/config", false, &None).to_str().unwrap(),
359                "~\\config"
360            )
361        }
362    }
363
364    /// Test that a linux oriented path expands on windows
365    #[test]
366    #[cfg_attr(not(windows), ignore)]
367    fn test_expand_home_windows() {
368        let path = "~/.aws/config";
369        assert_eq!(
370            expand_home(path, true, &Some("C:\\Users\\name".to_string()),)
371                .to_str()
372                .unwrap(),
373            "C:\\Users\\name\\.aws\\config"
374        );
375    }
376
377    #[tokio::test]
378    async fn programmatically_set_credentials_file_contents() {
379        let contents = "[default]\n\
380            aws_access_key_id = AKIAFAKE\n\
381            aws_secret_access_key = FAKE\n\
382            ";
383        let env = Env::from_slice(&[]);
384        let fs = Fs::from_slice(&[]);
385        let profile_files = EnvConfigFiles::builder()
386            .with_contents(EnvConfigFileKind::Credentials, contents)
387            .build();
388        let source = load(&env, &fs, &profile_files).await.unwrap();
389        assert_eq!(1, source.files.len());
390        assert_eq!("default", source.profile);
391        assert_eq!(contents, source.files[0].contents);
392    }
393
394    #[tokio::test]
395    async fn programmatically_set_credentials_file_path() {
396        let contents = "[default]\n\
397            aws_access_key_id = AKIAFAKE\n\
398            aws_secret_access_key = FAKE\n\
399            ";
400        let mut fs = HashMap::new();
401        fs.insert(
402            "/custom/path/to/credentials".to_string(),
403            contents.to_string(),
404        );
405
406        let fs = Fs::from_map(fs);
407        let env = Env::from_slice(&[]);
408        let profile_files = EnvConfigFiles::builder()
409            .with_file(
410                EnvConfigFileKind::Credentials,
411                "/custom/path/to/credentials",
412            )
413            .build();
414        let source = load(&env, &fs, &profile_files).await.unwrap();
415        assert_eq!(1, source.files.len());
416        assert_eq!("default", source.profile);
417        assert_eq!(contents, source.files[0].contents);
418    }
419
420    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
421    #[tokio::test]
422    #[cfg_attr(windows, ignore)]
423    async fn programmatically_include_default_files() {
424        let config_contents = "[default]\nregion = us-east-1";
425        let credentials_contents = "[default]\n\
426            aws_access_key_id = AKIAFAKE\n\
427            aws_secret_access_key = FAKE\n\
428            ";
429        let custom_contents = "[profile some-profile]\n\
430            aws_access_key_id = AKIAFAKEOTHER\n\
431            aws_secret_access_key = FAKEOTHER\n\
432            ";
433        let mut fs = HashMap::new();
434        fs.insert(
435            "/user/name/.aws/config".to_string(),
436            config_contents.to_string(),
437        );
438        fs.insert(
439            "/user/name/.aws/credentials".to_string(),
440            credentials_contents.to_string(),
441        );
442
443        let fs = Fs::from_map(fs);
444        let env = Env::from_slice(&[("HOME", "/user/name")]);
445        let profile_files = EnvConfigFiles::builder()
446            .with_contents(EnvConfigFileKind::Config, custom_contents)
447            .include_default_credentials_file(true)
448            .include_default_config_file(true)
449            .build();
450        let source = load(&env, &fs, &profile_files).await.unwrap();
451        assert_eq!(3, source.files.len());
452        assert_eq!("default", source.profile);
453        assert_eq!(config_contents, source.files[0].contents);
454        assert_eq!(credentials_contents, source.files[1].contents);
455        assert_eq!(custom_contents, source.files[2].contents);
456    }
457
458    #[tokio::test]
459    async fn default_files_must_not_error() {
460        let custom_contents = "[profile some-profile]\n\
461            aws_access_key_id = AKIAFAKEOTHER\n\
462            aws_secret_access_key = FAKEOTHER\n\
463            ";
464
465        let fs = Fs::from_slice(&[]);
466        let env = Env::from_slice(&[("HOME", "/user/name")]);
467        let profile_files = EnvConfigFiles::builder()
468            .with_contents(EnvConfigFileKind::Config, custom_contents)
469            .include_default_credentials_file(true)
470            .include_default_config_file(true)
471            .build();
472        let source = load(&env, &fs, &profile_files).await.unwrap();
473        assert_eq!(3, source.files.len());
474        assert_eq!("default", source.profile);
475        assert_eq!("", source.files[0].contents);
476        assert_eq!("", source.files[1].contents);
477        assert_eq!(custom_contents, source.files[2].contents);
478    }
479
480    #[tokio::test]
481    async fn misconfigured_programmatic_custom_profile_path_must_error() {
482        let fs = Fs::from_slice(&[]);
483        let env = Env::from_slice(&[]);
484        let profile_files = EnvConfigFiles::builder()
485            .with_file(EnvConfigFileKind::Config, "definitely-doesnt-exist")
486            .build();
487        assert!(matches!(
488            load(&env, &fs, &profile_files).await,
489            Err(EnvConfigFileLoadError::CouldNotReadFile(_))
490        ));
491    }
492}