diff --git a/CHANGELOG.next.toml b/CHANGELOG.next.toml index df706f8fa8..94b888a4d4 100644 --- a/CHANGELOG.next.toml +++ b/CHANGELOG.next.toml @@ -53,3 +53,31 @@ of breaking changes and how to resolve them. references = ["smithy-rs#1740", "smithy-rs#256"] meta = { "breaking" = true, "tada" = false, "bug" = false, "target" = "client" } author = "jdisanti" + +[[aws-sdk-rust]] +message = """ +It is now possible to programmatically customize the locations of the profile config/credentials files in `aws-config`: +```rust +use aws_config::profile::{ProfileFileCredentialsProvider, ProfileFileRegionProvider}; +use aws_config::profile::profile_file::{ProfileFiles, ProfileFileKind}; + +let profile_files = ProfileFiles::builder() + .with_file(ProfileFileKind::Credentials, "some/path/to/credentials-file") + .build(); +let credentials_provider = ProfileFileCredentialsProvider::builder() + .profile_files(profile_files.clone()) + .build(); +let region_provider = ProfileFileRegionProvider::builder() + .profile_files(profile_files) + .build(); + +let sdk_config = aws_config::from_env() + .credentials_provider(credentials_provider) + .region(region_provider) + .load() + .await; +``` +""" +references = ["aws-sdk-rust#237", "smithy-rs#1770"] +meta = { "breaking" = false, "tada" = true, "bug" = false } +author = "jdisanti" diff --git a/aws/rust-runtime/aws-config/src/imds/client.rs b/aws/rust-runtime/aws-config/src/imds/client.rs index a1e39def07..10e6d419ab 100644 --- a/aws/rust-runtime/aws-config/src/imds/client.rs +++ b/aws/rust-runtime/aws-config/src/imds/client.rs @@ -37,7 +37,7 @@ use tokio::sync::OnceCell; use crate::connector::expect_connector; use crate::imds::client::token::TokenMiddleware; -use crate::profile::ProfileParseError; +use crate::profile::credentials::ProfileFileError; use crate::provider_config::ProviderConfig; use crate::{profile, PKG_VERSION}; use aws_sdk_sso::config::timeout::TimeoutConfig; @@ -439,7 +439,7 @@ pub enum BuildError { InvalidEndpointMode(InvalidEndpointMode), /// The AWS Profile (e.g. `~/.aws/config`) was invalid - InvalidProfile(ProfileParseError), + InvalidProfile(ProfileFileError), /// The specified endpoint was not a valid URI InvalidEndpointUri(InvalidUri), @@ -626,7 +626,7 @@ impl EndpointSource { } EndpointSource::Env(env, fs) => { // load an endpoint override from the environment - let profile = profile::load(fs, env) + let profile = profile::load(fs, env, &Default::default()) .await .map_err(BuildError::InvalidProfile)?; let uri_override = if let Ok(uri) = env.get(env::ENDPOINT) { diff --git a/aws/rust-runtime/aws-config/src/profile/app_name.rs b/aws/rust-runtime/aws-config/src/profile/app_name.rs index 44b35fc174..e0af917967 100644 --- a/aws/rust-runtime/aws-config/src/profile/app_name.rs +++ b/aws/rust-runtime/aws-config/src/profile/app_name.rs @@ -5,6 +5,7 @@ //! Load an app name from an AWS profile +use super::profile_file::ProfileFiles; use crate::provider_config::ProviderConfig; use aws_types::app_name::AppName; use aws_types::os_shim_internal::{Env, Fs}; @@ -14,6 +15,8 @@ use aws_types::os_shim_internal::{Env, Fs}; /// This provider will attempt to shared AWS shared configuration and then read the /// `sdk-ua-app-id` property from the active profile. /// +#[doc = include_str!("location_of_profile_files.md")] +/// /// # Examples /// /// **Loads "my-app" as the app name** @@ -35,6 +38,7 @@ pub struct ProfileFileAppNameProvider { fs: Fs, env: Env, profile_override: Option, + profile_files: ProfileFiles, } impl ProfileFileAppNameProvider { @@ -46,6 +50,7 @@ impl ProfileFileAppNameProvider { fs: Fs::real(), env: Env::real(), profile_override: None, + profile_files: Default::default(), } } @@ -56,7 +61,7 @@ impl ProfileFileAppNameProvider { /// Parses the profile config and attempts to find an app name. pub async fn app_name(&self) -> Option { - let profile = super::parser::load(&self.fs, &self.env) + let profile = super::parser::load(&self.fs, &self.env, &self.profile_files) .await .map_err(|err| tracing::warn!(err = %err, "failed to parse profile")) .ok()?; @@ -82,6 +87,7 @@ impl ProfileFileAppNameProvider { pub struct Builder { config: Option, profile_override: Option, + profile_files: Option, } impl Builder { @@ -104,6 +110,7 @@ impl Builder { env: conf.env(), fs: conf.fs(), profile_override: self.profile_override, + profile_files: self.profile_files.unwrap_or_default(), } } } diff --git a/aws/rust-runtime/aws-config/src/profile/credentials.rs b/aws/rust-runtime/aws-config/src/profile/credentials.rs index 7889bb6e1f..1a025339cf 100644 --- a/aws/rust-runtime/aws-config/src/profile/credentials.rs +++ b/aws/rust-runtime/aws-config/src/profile/credentials.rs @@ -22,22 +22,21 @@ //! - `exec` which contains a chain representation of providers to implement passing bootstrapped credentials //! through a series of providers. +use crate::profile::credentials::exec::named::NamedProviderFactory; +use crate::profile::credentials::exec::{ClientConfiguration, ProviderChain}; +use crate::profile::parser::ProfileParseError; +use crate::profile::profile_file::ProfileFiles; +use crate::profile::Profile; +use crate::provider_config::ProviderConfig; +use aws_types::credentials::{self, future, CredentialsError, ProvideCredentials}; use std::borrow::Cow; use std::collections::HashMap; use std::error::Error; use std::fmt::{Display, Formatter}; +use std::path::PathBuf; use std::sync::Arc; - -use aws_types::credentials::{self, future, CredentialsError, ProvideCredentials}; - use tracing::Instrument; -use crate::profile::credentials::exec::named::NamedProviderFactory; -use crate::profile::credentials::exec::{ClientConfiguration, ProviderChain}; -use crate::profile::parser::ProfileParseError; -use crate::profile::Profile; -use crate::provider_config::ProviderConfig; - mod exec; mod repr; @@ -142,29 +141,14 @@ impl ProvideCredentials for ProfileFileCredentialsProvider { /// /// SSO can also be used as a source profile for assume role chains. /// -/// ## Location of Profile Files -/// * The location of the config file will be loaded from the `AWS_CONFIG_FILE` environment variable -/// with a fallback to `~/.aws/config` -/// * The location of the credentials file will be loaded from the `AWS_SHARED_CREDENTIALS_FILE` -/// environment variable with a fallback to `~/.aws/credentials` -/// -/// ## Home directory resolution -/// Home directory resolution is implemented to match the behavior of the CLI & Python. `~` is only -/// used for home directory resolution when it: -/// - Starts the path -/// - Is followed immediately by `/` or a platform specific separator. (On windows, `~/` and `~\` both -/// resolve to the home directory. -/// -/// When determining the home directory, the following environment variables are checked: -/// - `HOME` on all platforms -/// - `USERPROFILE` on Windows -/// - The concatenation of `HOMEDRIVE` and `HOMEPATH` on Windows (`$HOMEDRIVE$HOMEPATH`) +#[doc = include_str!("location_of_profile_files.md")] #[derive(Debug)] pub struct ProfileFileCredentialsProvider { factory: NamedProviderFactory, client_config: ClientConfiguration, provider_config: ProviderConfig, profile_override: Option, + profile_files: ProfileFiles, } impl ProfileFileCredentialsProvider { @@ -178,6 +162,7 @@ impl ProfileFileCredentialsProvider { &self.provider_config, &self.factory, self.profile_override.as_deref(), + &self.profile_files, ) .await .map_err(|err| match err { @@ -225,6 +210,13 @@ impl ProfileFileCredentialsProvider { } } +#[doc(hidden)] +#[derive(Debug)] +pub struct CouldNotReadProfileFile { + pub(crate) path: PathBuf, + pub(crate) cause: std::io::Error, +} + /// An Error building a Credential source from an AWS Profile #[derive(Debug)] #[non_exhaustive] @@ -283,6 +275,10 @@ pub enum ProfileFileError { /// The name of the provider name: String, }, + + /// A custom profile file location didn't exist or could not be read + #[non_exhaustive] + CouldNotReadProfileFile(CouldNotReadProfileFile), } impl ProfileFileError { @@ -326,6 +322,13 @@ impl Display for ProfileFileError { "profile `{}` did not contain credential information", profile ), + ProfileFileError::CouldNotReadProfileFile(details) => { + write!( + f, + "Failed to read custom profile file at {:?}", + details.path + ) + } } } } @@ -334,16 +337,24 @@ impl Error for ProfileFileError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { ProfileFileError::CouldNotParseProfile(err) => Some(err), + ProfileFileError::CouldNotReadProfileFile(details) => Some(&details.cause), _ => None, } } } +impl From for ProfileFileError { + fn from(err: ProfileParseError) -> Self { + ProfileFileError::CouldNotParseProfile(err) + } +} + /// Builder for [`ProfileFileCredentialsProvider`] #[derive(Debug, Default)] pub struct Builder { provider_config: Option, profile_override: Option, + profile_files: Option, custom_providers: HashMap, Arc>, } @@ -409,6 +420,12 @@ impl Builder { self } + /// Set the profile file that should be used by the [`ProfileFileCredentialsProvider`] + pub fn profile_files(mut self, profile_files: ProfileFiles) -> Self { + self.profile_files = Some(profile_files); + self + } + /// Builds a [`ProfileFileCredentialsProvider`] pub fn build(self) -> ProfileFileCredentialsProvider { let build_span = tracing::debug_span!("build_profile_provider"); @@ -453,6 +470,7 @@ impl Builder { }, provider_config: conf, profile_override: self.profile_override, + profile_files: self.profile_files.unwrap_or_default(), } } } @@ -461,13 +479,10 @@ async fn build_provider_chain( provider_config: &ProviderConfig, factory: &NamedProviderFactory, profile_override: Option<&str>, + profile_files: &ProfileFiles, ) -> Result { - let profile_set = super::parser::load(&provider_config.fs(), &provider_config.env()) - .await - .map_err(|err| { - tracing::warn!(err = %err, "failed to parse profile"); - ProfileFileError::CouldNotParseProfile(err) - })?; + let profile_set = + super::parser::load(&provider_config.fs(), &provider_config.env(), profile_files).await?; let repr = repr::resolve_chain(&profile_set, profile_override)?; tracing::info!(chain = ?repr, "constructed abstract provider from config file"); exec::ProviderChain::from_repr(provider_config, repr, factory) diff --git a/aws/rust-runtime/aws-config/src/profile/location_of_profile_files.md b/aws/rust-runtime/aws-config/src/profile/location_of_profile_files.md new file mode 100644 index 0000000000..d712da7ff5 --- /dev/null +++ b/aws/rust-runtime/aws-config/src/profile/location_of_profile_files.md @@ -0,0 +1,19 @@ +## Location of Profile Files +* The location of the config file will be loaded from the `AWS_CONFIG_FILE` environment variable +with a fallback to `~/.aws/config` +* The location of the credentials file will be loaded from the `AWS_SHARED_CREDENTIALS_FILE` +environment variable with a fallback to `~/.aws/credentials` + +The location of these files can also be customized programmatically using [`ProfileFiles`](crate::profile::profile_file::ProfileFiles). + +## Home directory resolution +Home directory resolution is implemented to match the behavior of the CLI & Python. `~` is only +used for home directory resolution when it: +- Starts the path +- Is followed immediately by `/` or a platform specific separator. (On windows, `~/` and `~\` both + resolve to the home directory. + +When determining the home directory, the following environment variables are checked: +- `HOME` on all platforms +- `USERPROFILE` on Windows +- The concatenation of `HOMEDRIVE` and `HOMEPATH` on Windows (`$HOMEDRIVE$HOMEPATH`) diff --git a/aws/rust-runtime/aws-config/src/profile/mod.rs b/aws/rust-runtime/aws-config/src/profile/mod.rs index 62071013ba..f1a81cad51 100644 --- a/aws/rust-runtime/aws-config/src/profile/mod.rs +++ b/aws/rust-runtime/aws-config/src/profile/mod.rs @@ -20,6 +20,7 @@ pub use parser::{load, Profile, ProfileSet, Property}; pub mod app_name; pub mod credentials; +pub mod profile_file; pub mod region; pub mod retry_config; diff --git a/aws/rust-runtime/aws-config/src/profile/parser.rs b/aws/rust-runtime/aws-config/src/profile/parser.rs index 3306cbb990..f80a5842a0 100644 --- a/aws/rust-runtime/aws-config/src/profile/parser.rs +++ b/aws/rust-runtime/aws-config/src/profile/parser.rs @@ -3,17 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -mod normalize; -mod parse; -mod source; - use crate::profile::parser::parse::parse_profile_file; -use crate::profile::parser::source::{FileKind, Source}; +use crate::profile::parser::source::Source; +use crate::profile::profile_file::ProfileFiles; use aws_types::os_shim_internal::{Env, Fs}; use std::borrow::Cow; use std::collections::HashMap; pub use self::parse::ProfileParseError; +use super::credentials::ProfileFileError; + +mod normalize; +mod parse; +mod source; /// Read & parse AWS config files /// @@ -22,23 +24,7 @@ pub use self::parse::ProfileParseError; /// Although the basic behavior is straightforward, there are number of nuances to maintain backwards /// compatibility with other SDKs enumerated below. /// -/// ## Location of Profile Files -/// * The location of the config file will be loaded from the `AWS_CONFIG_FILE` environment variable -/// with a fallback to `~/.aws/config` -/// * The location of the credentials file will be loaded from the `AWS_SHARED_CREDENTIALS_FILE` -/// environment variable with a fallback to `~/.aws/credentials` -/// -/// ## Home directory resolution -/// Home directory resolution is implemented to match the behavior of the CLI & Python. `~` is only -/// used for home directory resolution when it: -/// - Starts the path -/// - Is followed immediately by `/` or a platform specific separator. (On windows, `~/` and `~\` both -/// resolve to the home directory. -/// -/// When determining the home directory, the following environment variables are checked: -/// - `HOME` on all platforms -/// - `USERPROFILE` on Windows -/// - The concatenation of `HOMEDRIVE` and `HOMEPATH` on Windows (`$HOMEDRIVE$HOMEPATH`) +#[doc = include_str!("location_of_profile_files.md")] /// /// ## Profile file syntax /// @@ -66,9 +52,13 @@ pub use self::parse::ProfileParseError; /// [other] /// aws_access_key_id = 456 /// ``` -pub async fn load(fs: &Fs, env: &Env) -> Result { - let source = source::load(env, fs).await; - ProfileSet::parse(source) +pub async fn load( + fs: &Fs, + env: &Env, + profile_files: &ProfileFiles, +) -> Result { + let source = source::load(env, fs, profile_files).await?; + Ok(ProfileSet::parse(source)?) } /// A top-level configuration source containing multiple named profiles @@ -141,16 +131,9 @@ impl ProfileSet { let mut base = ProfileSet::empty(); base.selected_profile = source.profile; - normalize::merge_in( - &mut base, - parse_profile_file(&source.config_file)?, - FileKind::Config, - ); - normalize::merge_in( - &mut base, - parse_profile_file(&source.credentials_file)?, - FileKind::Credentials, - ); + for file in source.files { + normalize::merge_in(&mut base, parse_profile_file(&file)?, file.kind); + } Ok(base) } @@ -215,6 +198,7 @@ impl Property { #[cfg(test)] mod test { use crate::profile::parser::source::{File, Source}; + use crate::profile::profile_file::ProfileFileKind; use crate::profile::ProfileSet; use arbitrary::{Arbitrary, Unstructured}; use serde::Deserialize; @@ -276,14 +260,18 @@ mod test { let (conf, creds): (Option<&str>, Option<&str>) = Arbitrary::arbitrary(&mut unstructured)?; let profile_source = Source { - config_file: File { - path: "~/.aws/config".to_string(), - contents: conf.unwrap_or_default().to_string(), - }, - credentials_file: File { - path: "~/.aws/config".to_string(), - contents: creds.unwrap_or_default().to_string(), - }, + files: vec![ + File { + kind: ProfileFileKind::Config, + path: Some("~/.aws/config".to_string()), + contents: conf.unwrap_or_default().to_string(), + }, + File { + kind: ProfileFileKind::Credentials, + path: Some("~/.aws/credentials".to_string()), + contents: creds.unwrap_or_default().to_string(), + }, + ], profile: "default".into(), }; // don't care if parse fails, just don't panic @@ -313,14 +301,18 @@ mod test { fn make_source(input: ParserInput) -> Source { Source { - config_file: File { - path: "~/.aws/config".to_string(), - contents: input.config_file.unwrap_or_default(), - }, - credentials_file: File { - path: "~/.aws/credentials".to_string(), - contents: input.credentials_file.unwrap_or_default(), - }, + files: vec![ + File { + kind: ProfileFileKind::Config, + path: Some("~/.aws/config".to_string()), + contents: input.config_file.unwrap_or_default(), + }, + File { + kind: ProfileFileKind::Credentials, + path: Some("~/.aws/credentials".to_string()), + contents: input.credentials_file.unwrap_or_default(), + }, + ], profile: "default".into(), } } diff --git a/aws/rust-runtime/aws-config/src/profile/parser/normalize.rs b/aws/rust-runtime/aws-config/src/profile/parser/normalize.rs index 81ad64327b..2e645c3f11 100644 --- a/aws/rust-runtime/aws-config/src/profile/parser/normalize.rs +++ b/aws/rust-runtime/aws-config/src/profile/parser/normalize.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; use std::collections::HashMap; use crate::profile::parser::parse::{RawProfileSet, WHITESPACE}; -use crate::profile::parser::source::FileKind; +use crate::profile::profile_file::ProfileFileKind; use crate::profile::{Profile, ProfileSet, Property}; const DEFAULT: &str = "default"; @@ -38,7 +38,7 @@ impl ProfileName<'_> { /// 1. `name` must ALWAYS be a valid identifier /// 2. For Config files, the profile must either be `default` or it must have a profile prefix /// 3. For credentials files, the profile name MUST NOT have a profile prefix - fn valid_for(self, kind: FileKind) -> Result { + fn valid_for(self, kind: ProfileFileKind) -> Result { if validate_identifier(self.name).is_err() { return Err(format!( "profile `{}` ignored because `{}` was not a valid identifier", @@ -46,17 +46,17 @@ impl ProfileName<'_> { )); } match (self.name, kind, self.has_profile_prefix) { - (_, FileKind::Config, true) => Ok(self), - (DEFAULT, FileKind::Config, false) => Ok(self), - (_not_default, FileKind::Config, false) => Err(format!( + (_, ProfileFileKind::Config, true) => Ok(self), + (DEFAULT, ProfileFileKind::Config, false) => Ok(self), + (_not_default, ProfileFileKind::Config, false) => Err(format!( "profile `{}` ignored because config profiles must be of the form `[profile ]`", self.name )), - (_, FileKind::Credentials, true) => Err(format!( + (_, ProfileFileKind::Credentials, true) => Err(format!( "profile `{}` ignored because credential profiles must NOT begin with `profile`", self.name )), - (_, FileKind::Credentials, false) => Ok(self), + (_, ProfileFileKind::Credentials, false) => Ok(self), } } } @@ -68,7 +68,11 @@ impl ProfileName<'_> { /// - Profile names are validated (see `validate_profile_name`) /// - A profile named `profile default` takes priority over a profile named `default`. /// - Profiles with identical names are merged -pub(super) fn merge_in(base: &mut ProfileSet, raw_profile_set: RawProfileSet<'_>, kind: FileKind) { +pub(super) fn merge_in( + base: &mut ProfileSet, + raw_profile_set: RawProfileSet<'_>, + kind: ProfileFileKind, +) { // parse / validate profile names let validated_profiles = raw_profile_set .into_iter() @@ -148,11 +152,11 @@ mod tests { use tracing_test::traced_test; use crate::profile::parser::parse::RawProfileSet; - use crate::profile::parser::source::FileKind; use crate::profile::ProfileSet; use super::{merge_in, ProfileName}; use crate::profile::parser::normalize::validate_identifier; + use crate::profile::profile_file::ProfileFileKind; #[test] fn profile_name_parsing() { @@ -219,7 +223,7 @@ mod tests { out }); let mut base = ProfileSet::empty(); - merge_in(&mut base, profile, FileKind::Config); + merge_in(&mut base, profile, ProfileFileKind::Config); assert!(base .get_profile("default") .expect("contains default profile") @@ -235,7 +239,7 @@ mod tests { fn invalid_profile_generates_warning() { let mut profile: RawProfileSet<'_> = HashMap::new(); profile.insert("foo", HashMap::new()); - merge_in(&mut ProfileSet::empty(), profile, FileKind::Config); + merge_in(&mut ProfileSet::empty(), profile, ProfileFileKind::Config); assert!(logs_contain("profile `foo` ignored")); } } diff --git a/aws/rust-runtime/aws-config/src/profile/parser/parse.rs b/aws/rust-runtime/aws-config/src/profile/parser/parse.rs index b1d43e58b2..1d3c3acb05 100644 --- a/aws/rust-runtime/aws-config/src/profile/parser/parse.rs +++ b/aws/rust-runtime/aws-config/src/profile/parser/parse.rs @@ -110,7 +110,7 @@ pub(super) fn parse_profile_file(file: &File) -> Result, Profi state: State::Starting, location: Location { line_number: 0, - path: file.path.to_string(), + path: file.path.clone().unwrap_or_default(), }, }; parser.parse_profile(&file.contents)?; @@ -290,6 +290,7 @@ mod test { use super::{parse_profile_file, prepare_line, Location}; use crate::profile::parser::parse::{parse_property_line, PropertyError}; use crate::profile::parser::source::File; + use crate::profile::profile_file::ProfileFileKind; // most test cases covered by the JSON test suite @@ -330,7 +331,8 @@ mod test { #[test] fn error_line_numbers() { let file = File { - path: "~/.aws/config".into(), + kind: ProfileFileKind::Config, + path: Some("~/.aws/config".into()), contents: "[default\nk=v".into(), }; let err = parse_profile_file(&file).expect_err("parsing should fail"); diff --git a/aws/rust-runtime/aws-config/src/profile/parser/source.rs b/aws/rust-runtime/aws-config/src/profile/parser/source.rs index fd99f40e7d..50057805da 100644 --- a/aws/rust-runtime/aws-config/src/profile/parser/source.rs +++ b/aws/rust-runtime/aws-config/src/profile/parser/source.rs @@ -4,6 +4,8 @@ */ use crate::fs_util::{home_dir, Os}; +use crate::profile::credentials::{CouldNotReadProfileFile, ProfileFileError}; +use crate::profile::profile_file::{ProfileFile, ProfileFileKind, ProfileFiles}; use aws_types::os_shim_internal; use std::borrow::Cow; use std::io::ErrorKind; @@ -16,11 +18,8 @@ const HOME_EXPANSION_FAILURE_WARNING: &str = /// In-memory source of profile data pub(super) struct Source { - /// Contents and path of ~/.aws/config - pub(super) config_file: File, - - /// Contents and path of ~/.aws/credentials - pub(super) credentials_file: File, + /// Profile file sources + pub(super) files: Vec, /// Profile to use /// @@ -30,49 +29,44 @@ pub(super) struct Source { /// In-memory configuration file pub(super) struct File { - pub(super) path: String, + pub(super) kind: ProfileFileKind, + pub(super) path: Option, pub(super) contents: String, } -#[derive(Clone, Copy)] -pub(super) enum FileKind { - Config, - Credentials, -} - -impl FileKind { - fn default_path(&self) -> &'static str { - match &self { - FileKind::Credentials => "~/.aws/credentials", - FileKind::Config => "~/.aws/config", - } - } +/// Load a [Source](Source) from a given environment and filesystem. +pub(super) async fn load( + proc_env: &os_shim_internal::Env, + fs: &os_shim_internal::Fs, + profile_files: &ProfileFiles, +) -> Result { + let home = home_dir(proc_env, Os::real()); - fn override_environment_variable(&self) -> &'static str { - match &self { - FileKind::Config => "AWS_CONFIG_FILE", - FileKind::Credentials => "AWS_SHARED_CREDENTIALS_FILE", - } + let mut files = Vec::new(); + for file in &profile_files.files { + let file = load_config_file(file, &home, fs, proc_env) + .instrument(tracing::debug_span!("load_config_file", file = ?file)) + .await?; + files.push(file); } -} -/// Load a [Source](Source) from a given environment and filesystem. -pub(super) async fn load(proc_env: &os_shim_internal::Env, fs: &os_shim_internal::Fs) -> Source { - let home = home_dir(proc_env, Os::real()); - let config = load_config_file(FileKind::Config, &home, fs, proc_env) - .instrument(tracing::debug_span!("load_config_file")) - .await; - let credentials = load_config_file(FileKind::Credentials, &home, fs, proc_env) - .instrument(tracing::debug_span!("load_credentials_file")) - .await; - - Source { - config_file: config, - credentials_file: credentials, + Ok(Source { + files, profile: proc_env .get("AWS_PROFILE") .map(Cow::Owned) .unwrap_or(Cow::Borrowed("default")), + }) +} + +fn file_contents_to_string(path: &Path, contents: Vec) -> String { + // if the file is not valid utf-8, log a warning and use an empty file instead + match String::from_utf8(contents) { + Ok(contents) => contents, + Err(e) => { + tracing::warn!(path = ?path, error = %e, "config file did not contain utf-8 encoded data"); + Default::default() + } } } @@ -87,53 +81,74 @@ pub(super) async fn load(proc_env: &os_shim_internal::Env, fs: &os_shim_internal /// * `fs`: Filesystem abstraction /// * `environment`: Process environment abstraction async fn load_config_file( - kind: FileKind, + source: &ProfileFile, home_directory: &Option, fs: &os_shim_internal::Fs, environment: &os_shim_internal::Env, -) -> File { - let (path_is_default, path) = environment - .get(kind.override_environment_variable()) - .map(|p| (false, Cow::Owned(p))) - .ok() - .unwrap_or_else(|| (true, kind.default_path().into())); - let expanded = expand_home(path.as_ref(), path_is_default, home_directory); - if path != expanded.to_string_lossy() { - tracing::debug!(before = ?path, after = ?expanded, "home directory expanded"); - } - // read the data at the specified path - // if the path does not exist, log a warning but pretend it was actually an empty file - let data = match fs.read_to_end(&expanded).await { - Ok(data) => data, - Err(e) => { - match e.kind() { - ErrorKind::NotFound if path == kind.default_path() => { - tracing::debug!(path = %path, "config file not found") - } - ErrorKind::NotFound if path != kind.default_path() => { - // in the case where the user overrode the path with an environment variable, - // log more loudly than the case where the default path was missing - tracing::warn!(path = %path, env = %kind.override_environment_variable(), "config file overridden via environment variable not found") +) -> Result { + let (path, kind, contents) = match source { + ProfileFile::Default(kind) => { + let (path_is_default, path) = environment + .get(kind.override_environment_variable()) + .map(|p| (false, Cow::Owned(p))) + .ok() + .unwrap_or_else(|| (true, kind.default_path().into())); + let expanded = expand_home(path.as_ref(), path_is_default, home_directory); + if path != expanded.to_string_lossy() { + tracing::debug!(before = ?path, after = ?expanded, "home directory expanded"); + } + // read the data at the specified path + // if the path does not exist, log a warning but pretend it was actually an empty file + let data = match fs.read_to_end(&expanded).await { + Ok(data) => data, + Err(e) => { + // Important: The default config/credentials files MUST NOT return an error + match e.kind() { + ErrorKind::NotFound if path == kind.default_path() => { + tracing::debug!(path = %path, "config file not found") + } + ErrorKind::NotFound if path != kind.default_path() => { + // in the case where the user overrode the path with an environment variable, + // log more loudly than the case where the default path was missing + tracing::warn!(path = %path, env = %kind.override_environment_variable(), "config file overridden via environment variable not found") + } + _other => { + tracing::warn!(path = %path, error = %e, "failed to read config file") + } + }; + Default::default() } - _other => tracing::warn!(path = %path, error = %e, "failed to read config file"), }; - Default::default() + let contents = file_contents_to_string(&expanded, data); + (Some(Cow::Owned(expanded)), kind, contents) } - }; - // if the file is not valid utf-8, log a warning and use an empty file instead - let data = match String::from_utf8(data) { - Ok(data) => data, - Err(e) => { - tracing::warn!(path = %path, error = %e, "config file did not contain utf-8 encoded data"); - Default::default() + ProfileFile::FilePath { kind, path } => { + let data = match fs.read_to_end(&path).await { + Ok(data) => data, + Err(e) => { + return Err(ProfileFileError::CouldNotReadProfileFile( + CouldNotReadProfileFile { + path: path.clone(), + cause: e, + }, + )) + } + }; + ( + Some(Cow::Borrowed(path)), + kind, + file_contents_to_string(path, data), + ) } + ProfileFile::FileContents { kind, contents } => (None, kind, contents.clone()), }; - tracing::debug!(path = %path, size = ?data.len(), "config file loaded"); - File { + tracing::debug!(path = ?path, size = ?contents.len(), "config file loaded"); + Ok(File { + kind: *kind, // lossy is OK here, the name of this file is just for debugging purposes - path: expanded.to_string_lossy().into(), - contents: data, - } + path: path.map(|p| p.to_string_lossy().into()), + contents, + }) } fn expand_home( @@ -179,9 +194,11 @@ fn expand_home( #[cfg(test)] mod tests { + use crate::profile::credentials::ProfileFileError; use crate::profile::parser::source::{ - expand_home, load, load_config_file, FileKind, HOME_EXPANSION_FAILURE_WARNING, + expand_home, load, load_config_file, HOME_EXPANSION_FAILURE_WARNING, }; + use crate::profile::profile_file::{ProfileFile, ProfileFileKind, ProfileFiles}; use aws_types::os_shim_internal::{Env, Fs}; use futures_util::FutureExt; use serde::Deserialize; @@ -243,7 +260,7 @@ mod tests { let fs = Fs::from_map(fs); - let _src = load(&env, &fs).now_or_never(); + let _src = load(&env, &fs, &Default::default()).now_or_never(); assert!(logs_contain("config file loaded")); assert!(logs_contain("performing home directory substitution")); } @@ -254,7 +271,13 @@ mod tests { let env = Env::from_slice(&[]); let fs = Fs::from_slice(&[]); - let _src = load_config_file(FileKind::Config, &None, &fs, &env).now_or_never(); + let _src = load_config_file( + &ProfileFile::Default(ProfileFileKind::Config), + &None, + &fs, + &env, + ) + .now_or_never(); assert!(!logs_contain(HOME_EXPANSION_FAILURE_WARNING)); } @@ -264,7 +287,13 @@ mod tests { let env = Env::from_slice(&[("AWS_CONFIG_FILE", "~/some/path")]); let fs = Fs::from_slice(&[]); - let _src = load_config_file(FileKind::Config, &None, &fs, &env).now_or_never(); + let _src = load_config_file( + &ProfileFile::Default(ProfileFileKind::Config), + &None, + &fs, + &env, + ) + .now_or_never(); assert!(logs_contain(HOME_EXPANSION_FAILURE_WARNING)); } @@ -274,17 +303,19 @@ mod tests { let platform_matches = (cfg!(windows) && test_case.platform == "windows") || (!cfg!(windows) && test_case.platform != "windows"); if platform_matches { - let source = load(&env, &fs).await; + let source = load(&env, &fs, &Default::default()).await.unwrap(); if let Some(expected_profile) = test_case.profile { assert_eq!(source.profile, expected_profile, "{}", &test_case.name); } assert_eq!( - source.config_file.path, test_case.config_location, + source.files[0].path, + Some(test_case.config_location), "{}", &test_case.name ); assert_eq!( - source.credentials_file.path, test_case.credentials_location, + source.files[1].path, + Some(test_case.credentials_location), "{}", &test_case.name ) @@ -337,4 +368,115 @@ mod tests { "C:\\Users\\name\\.aws\\config" ); } + + #[tokio::test] + async fn programmatically_set_credentials_file_contents() { + let contents = "[default]\n\ + aws_access_key_id = AKIAFAKE\n\ + aws_secret_access_key = FAKE\n\ + "; + let env = Env::from_slice(&[]); + let fs = Fs::from_slice(&[]); + let profile_files = ProfileFiles::builder() + .with_contents(ProfileFileKind::Credentials, contents) + .build(); + let source = load(&env, &fs, &profile_files).await.unwrap(); + assert_eq!(1, source.files.len()); + assert_eq!("default", source.profile); + assert_eq!(contents, source.files[0].contents); + } + + #[tokio::test] + async fn programmatically_set_credentials_file_path() { + let contents = "[default]\n\ + aws_access_key_id = AKIAFAKE\n\ + aws_secret_access_key = FAKE\n\ + "; + let mut fs = HashMap::new(); + fs.insert( + "/custom/path/to/credentials".to_string(), + contents.to_string(), + ); + + let fs = Fs::from_map(fs); + let env = Env::from_slice(&[]); + let profile_files = ProfileFiles::builder() + .with_file(ProfileFileKind::Credentials, "/custom/path/to/credentials") + .build(); + let source = load(&env, &fs, &profile_files).await.unwrap(); + assert_eq!(1, source.files.len()); + assert_eq!("default", source.profile); + assert_eq!(contents, source.files[0].contents); + } + + #[tokio::test] + async fn programmatically_include_default_files() { + let config_contents = "[default]\nregion = us-east-1"; + let credentials_contents = "[default]\n\ + aws_access_key_id = AKIAFAKE\n\ + aws_secret_access_key = FAKE\n\ + "; + let custom_contents = "[profile some-profile]\n\ + aws_access_key_id = AKIAFAKEOTHER\n\ + aws_secret_access_key = FAKEOTHER\n\ + "; + let mut fs = HashMap::new(); + fs.insert( + "/user/name/.aws/config".to_string(), + config_contents.to_string(), + ); + fs.insert( + "/user/name/.aws/credentials".to_string(), + credentials_contents.to_string(), + ); + + let fs = Fs::from_map(fs); + let env = Env::from_slice(&[("HOME", "/user/name")]); + let profile_files = ProfileFiles::builder() + .with_contents(ProfileFileKind::Config, custom_contents) + .include_default_credentials_file(true) + .include_default_config_file(true) + .build(); + let source = load(&env, &fs, &profile_files).await.unwrap(); + assert_eq!(3, source.files.len()); + assert_eq!("default", source.profile); + assert_eq!(config_contents, source.files[0].contents); + assert_eq!(credentials_contents, source.files[1].contents); + assert_eq!(custom_contents, source.files[2].contents); + } + + #[tokio::test] + async fn default_files_must_not_error() { + let custom_contents = "[profile some-profile]\n\ + aws_access_key_id = AKIAFAKEOTHER\n\ + aws_secret_access_key = FAKEOTHER\n\ + "; + + let fs = Fs::from_slice(&[]); + let env = Env::from_slice(&[("HOME", "/user/name")]); + let profile_files = ProfileFiles::builder() + .with_contents(ProfileFileKind::Config, custom_contents) + .include_default_credentials_file(true) + .include_default_config_file(true) + .build(); + let source = load(&env, &fs, &profile_files).await.unwrap(); + assert_eq!(3, source.files.len()); + assert_eq!("default", source.profile); + assert_eq!("", source.files[0].contents); + assert_eq!("", source.files[1].contents); + assert_eq!(custom_contents, source.files[2].contents); + } + + #[tokio::test] + async fn misconfigured_programmatic_custom_profile_path_must_error() { + let fs = Fs::from_slice(&[]); + let env = Env::from_slice(&[]); + let profile_files = ProfileFiles::builder() + .with_file(ProfileFileKind::Config, "definitely-doesnt-exist") + .build(); + assert!(matches!( + load(&env, &fs, &profile_files).await, + Err(ProfileFileError::CouldNotReadProfileFile(_)) + )); + } } diff --git a/aws/rust-runtime/aws-config/src/profile/profile_file.rs b/aws/rust-runtime/aws-config/src/profile/profile_file.rs new file mode 100644 index 0000000000..76bdfb1fba --- /dev/null +++ b/aws/rust-runtime/aws-config/src/profile/profile_file.rs @@ -0,0 +1,257 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Config structs to programmatically customize the profile files that get loaded + +use std::fmt; +use std::path::PathBuf; + +/// Provides the ability to programmatically override the profile files that get loaded by the SDK. +/// +/// The [`Default`] for `ProfileFiles` includes the default SDK config and credential files located in +/// `~/.aws/config` and `~/.aws/credentials` respectively. +/// +/// Any number of config and credential files may be added to the `ProfileFiles` file set, with the +/// only requirement being that there is at least one of them. Custom file locations that are added +/// will produce errors if they don't exist, while the default config/credentials files paths are +/// allowed to not exist even if they're included. +/// +/// # Example: Using a custom profile file path for credentials and region +/// +/// ``` +/// use aws_config::profile::{ProfileFileCredentialsProvider, ProfileFileRegionProvider}; +/// use aws_config::profile::profile_file::{ProfileFiles, ProfileFileKind}; +/// +/// # async fn example() { +/// let profile_files = ProfileFiles::builder() +/// .with_file(ProfileFileKind::Credentials, "some/path/to/credentials-file") +/// .build(); +/// let credentials_provider = ProfileFileCredentialsProvider::builder() +/// .profile_files(profile_files.clone()) +/// .build(); +/// let region_provider = ProfileFileRegionProvider::builder() +/// .profile_files(profile_files) +/// .build(); +/// +/// let sdk_config = aws_config::from_env() +/// .credentials_provider(credentials_provider) +/// .region(region_provider) +/// .load() +/// .await; +/// # } +/// ``` +#[derive(Clone, Debug)] +pub struct ProfileFiles { + pub(crate) files: Vec, +} + +impl ProfileFiles { + /// Returns a builder to create `ProfileFiles` + pub fn builder() -> Builder { + Builder::new() + } +} + +impl Default for ProfileFiles { + fn default() -> Self { + Self { + files: vec![ + ProfileFile::Default(ProfileFileKind::Config), + ProfileFile::Default(ProfileFileKind::Credentials), + ], + } + } +} + +/// Profile file type (config or credentials) +#[derive(Copy, Clone, Debug)] +pub enum ProfileFileKind { + /// The SDK config file that typically resides in `~/.aws/config` + Config, + /// The SDK credentials file that typically resides in `~/.aws/credentials` + Credentials, +} + +impl ProfileFileKind { + pub(crate) fn default_path(&self) -> &'static str { + match &self { + ProfileFileKind::Credentials => "~/.aws/credentials", + ProfileFileKind::Config => "~/.aws/config", + } + } + + pub(crate) fn override_environment_variable(&self) -> &'static str { + match &self { + ProfileFileKind::Config => "AWS_CONFIG_FILE", + ProfileFileKind::Credentials => "AWS_SHARED_CREDENTIALS_FILE", + } + } +} + +/// A single profile file within a [`ProfileFiles`] file set. +#[derive(Clone)] +pub(crate) enum ProfileFile { + /// One of the default profile files (config or credentials in their default locations) + Default(ProfileFileKind), + /// A profile file at a custom location + FilePath { + kind: ProfileFileKind, + path: PathBuf, + }, + /// The direct contents of a profile file + FileContents { + kind: ProfileFileKind, + contents: String, + }, +} + +impl fmt::Debug for ProfileFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Default(kind) => f.debug_tuple("Default").field(kind).finish(), + Self::FilePath { kind, path } => f + .debug_struct("FilePath") + .field("kind", kind) + .field("path", path) + .finish(), + // Security: Redact the file contents since they may have credentials in them + Self::FileContents { kind, contents: _ } => f + .debug_struct("FileContents") + .field("kind", kind) + .field("contents", &"** redacted **") + .finish(), + } + } +} + +/// Builder for [`ProfileFiles`]. +#[derive(Clone, Default, Debug)] +pub struct Builder { + with_config: bool, + with_credentials: bool, + custom_sources: Vec, +} + +impl Builder { + /// Creates a new builder instance. + pub fn new() -> Self { + Default::default() + } + + /// Include the default SDK config file in the list of profile files to be loaded. + /// + /// The default SDK config typically resides in `~/.aws/config`. When this flag is enabled, + /// this config file will be included in the profile files that get loaded in the built + /// [`ProfileFiles`] file set. + /// + /// This flag defaults to `false` when using the builder to construct [`ProfileFiles`]. + pub fn include_default_config_file(mut self, include_default_config_file: bool) -> Self { + self.with_config = include_default_config_file; + self + } + + /// Include the default SDK credentials file in the list of profile files to be loaded. + /// + /// The default SDK config typically resides in `~/.aws/credentials`. When this flag is enabled, + /// this credentials file will be included in the profile files that get loaded in the built + /// [`ProfileFiles`] file set. + /// + /// This flag defaults to `false` when using the builder to construct [`ProfileFiles`]. + pub fn include_default_credentials_file( + mut self, + include_default_credentials_file: bool, + ) -> Self { + self.with_credentials = include_default_credentials_file; + self + } + + /// Include a custom `file` in the list of profile files to be loaded. + /// + /// The `kind` informs the parser how to treat the file. If it's intended to be like + /// the SDK credentials file typically in `~/.aws/config`, then use [`ProfileFileKind::Config`]. + /// Otherwise, use [`ProfileFileKind::Credentials`]. + pub fn with_file(mut self, kind: ProfileFileKind, file: impl Into) -> Self { + self.custom_sources.push(ProfileFile::FilePath { + kind, + path: file.into(), + }); + self + } + + /// Include custom file `contents` in the list of profile files to be loaded. + /// + /// The `kind` informs the parser how to treat the file. If it's intended to be like + /// the SDK credentials file typically in `~/.aws/config`, then use [`ProfileFileKind::Config`]. + /// Otherwise, use [`ProfileFileKind::Credentials`]. + pub fn with_contents(mut self, kind: ProfileFileKind, contents: impl Into) -> Self { + self.custom_sources.push(ProfileFile::FileContents { + kind, + contents: contents.into(), + }); + self + } + + /// Build the [`ProfileFiles`] file set. + pub fn build(self) -> ProfileFiles { + let mut files = self.custom_sources; + if self.with_credentials { + files.insert(0, ProfileFile::Default(ProfileFileKind::Credentials)); + } + if self.with_config { + files.insert(0, ProfileFile::Default(ProfileFileKind::Config)); + } + if files.is_empty() { + panic!("At least one profile file must be included in the `ProfileFiles` file set."); + } + ProfileFiles { files } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redact_file_contents_in_profile_file_debug() { + let profile_file = ProfileFile::FileContents { + kind: ProfileFileKind::Config, + contents: "sensitive_contents".into(), + }; + let debug = format!("{:?}", profile_file); + assert!(!debug.contains("sensitive_contents")); + assert!(debug.contains("** redacted **")); + } + + #[test] + fn build_correctly_orders_default_config_credentials() { + let profile_files = ProfileFiles::builder() + .with_file(ProfileFileKind::Config, "foo") + .include_default_credentials_file(true) + .include_default_config_file(true) + .build(); + assert_eq!(3, profile_files.files.len()); + assert!(matches!( + profile_files.files[0], + ProfileFile::Default(ProfileFileKind::Config) + )); + assert!(matches!( + profile_files.files[1], + ProfileFile::Default(ProfileFileKind::Credentials) + )); + assert!(matches!( + profile_files.files[2], + ProfileFile::FilePath { + kind: ProfileFileKind::Config, + path: _ + } + )); + } + + #[test] + #[should_panic] + fn empty_builder_panics() { + ProfileFiles::builder().build(); + } +} diff --git a/aws/rust-runtime/aws-config/src/profile/region.rs b/aws/rust-runtime/aws-config/src/profile/region.rs index e294ed61ef..547bff175f 100644 --- a/aws/rust-runtime/aws-config/src/profile/region.rs +++ b/aws/rust-runtime/aws-config/src/profile/region.rs @@ -10,6 +10,7 @@ use crate::provider_config::ProviderConfig; use aws_types::os_shim_internal::{Env, Fs}; use aws_types::region::Region; +use super::profile_file::ProfileFiles; use super::ProfileSet; /// Load a region from a profile file @@ -17,6 +18,8 @@ use super::ProfileSet; /// This provider will attempt to load AWS shared configuration, then read the `region` property /// from the active profile. /// +#[doc = include_str!("location_of_profile_files.md")] +/// /// # Examples /// /// **Loads "us-west-2" as the region** @@ -39,6 +42,7 @@ pub struct ProfileFileRegionProvider { fs: Fs, env: Env, profile_override: Option, + profile_files: ProfileFiles, } /// Builder for [ProfileFileRegionProvider] @@ -46,6 +50,7 @@ pub struct ProfileFileRegionProvider { pub struct Builder { config: Option, profile_override: Option, + profile_files: Option, } impl Builder { @@ -55,12 +60,18 @@ impl Builder { self } - /// Override the profile name used by the [ProfileFileRegionProvider] + /// Override the profile name used by the [`ProfileFileRegionProvider`] pub fn profile_name(mut self, profile_name: impl Into) -> Self { self.profile_override = Some(profile_name.into()); self } + /// Set the profile file that should be used by the [`ProfileFileRegionProvider`] + pub fn profile_files(mut self, profile_files: ProfileFiles) -> Self { + self.profile_files = Some(profile_files); + self + } + /// Build a [ProfileFileRegionProvider] from this builder pub fn build(self) -> ProfileFileRegionProvider { let conf = self.config.unwrap_or_default(); @@ -68,6 +79,7 @@ impl Builder { env: conf.env(), fs: conf.fs(), profile_override: self.profile_override, + profile_files: self.profile_files.unwrap_or_default(), } } } @@ -81,6 +93,7 @@ impl ProfileFileRegionProvider { fs: Fs::real(), env: Env::real(), profile_override: None, + profile_files: ProfileFiles::default(), } } @@ -90,7 +103,7 @@ impl ProfileFileRegionProvider { } async fn region(&self) -> Option { - let profile_set = super::parser::load(&self.fs, &self.env) + let profile_set = super::parser::load(&self.fs, &self.env, &self.profile_files) .await .map_err(|err| tracing::warn!(err = %err, "failed to parse profile")) .ok()?; diff --git a/aws/rust-runtime/aws-config/src/profile/retry_config.rs b/aws/rust-runtime/aws-config/src/profile/retry_config.rs index c876d82f02..e51a39fdde 100644 --- a/aws/rust-runtime/aws-config/src/profile/retry_config.rs +++ b/aws/rust-runtime/aws-config/src/profile/retry_config.rs @@ -10,6 +10,7 @@ use std::str::FromStr; use aws_smithy_types::retry::{RetryConfigBuilder, RetryConfigErr, RetryMode}; use aws_types::os_shim_internal::{Env, Fs}; +use super::profile_file::ProfileFiles; use crate::provider_config::ProviderConfig; /// Load retry configuration properties from a profile file @@ -17,6 +18,8 @@ use crate::provider_config::ProviderConfig; /// This provider will attempt to load AWS shared configuration, then read retry configuration properties /// from the active profile. /// +#[doc = include_str!("location_of_profile_files.md")] +/// /// # Examples /// /// **Loads 2 as the `max_attempts` to make when sending a request** @@ -38,6 +41,7 @@ pub struct ProfileFileRetryConfigProvider { fs: Fs, env: Env, profile_override: Option, + profile_files: ProfileFiles, } /// Builder for [ProfileFileRetryConfigProvider] @@ -45,6 +49,7 @@ pub struct ProfileFileRetryConfigProvider { pub struct Builder { config: Option, profile_override: Option, + profile_files: Option, } impl Builder { @@ -60,6 +65,12 @@ impl Builder { self } + /// Set the profile file that should be used by the [`ProfileFileRetryConfigProvider`] + pub fn profile_files(mut self, profile_files: ProfileFiles) -> Self { + self.profile_files = Some(profile_files); + self + } + /// Build a [ProfileFileRetryConfigProvider] from this builder pub fn build(self) -> ProfileFileRetryConfigProvider { let conf = self.config.unwrap_or_default(); @@ -67,6 +78,7 @@ impl Builder { env: conf.env(), fs: conf.fs(), profile_override: self.profile_override, + profile_files: self.profile_files.unwrap_or_default(), } } } @@ -80,6 +92,7 @@ impl ProfileFileRetryConfigProvider { fs: Fs::real(), env: Env::real(), profile_override: None, + profile_files: Default::default(), } } @@ -90,7 +103,7 @@ impl ProfileFileRetryConfigProvider { /// Attempt to create a new RetryConfigBuilder from a profile file. pub async fn retry_config_builder(&self) -> Result { - let profile = match super::parser::load(&self.fs, &self.env).await { + let profile = match super::parser::load(&self.fs, &self.env, &self.profile_files).await { Ok(profile) => profile, Err(err) => { tracing::warn!(err = %err, "failed to parse profile");