aws_runtime/env_config/
normalize.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::env_config::file::EnvConfigFileKind;
7use crate::env_config::parse::{RawProfileSet, WHITESPACE};
8use crate::env_config::property::{PropertiesKey, Property};
9use crate::env_config::section::{EnvConfigSections, Profile, Section, SsoSession};
10use std::borrow::Cow;
11use std::collections::HashMap;
12
13const DEFAULT: &str = "default";
14const PROFILE_PREFIX: &str = "profile";
15const SSO_SESSION_PREFIX: &str = "sso-session";
16
17/// Any section like `[<prefix> <suffix>]` or `[<suffix-only>]`
18#[derive(Eq, PartialEq, Hash, Debug)]
19struct SectionPair<'a> {
20    prefix: Option<Cow<'a, str>>,
21    suffix: Cow<'a, str>,
22}
23
24impl SectionPair<'_> {
25    fn is_unprefixed_default(&self) -> bool {
26        self.prefix.is_none() && self.suffix == DEFAULT
27    }
28
29    fn is_prefixed_default(&self) -> bool {
30        self.prefix.as_deref() == Some(PROFILE_PREFIX) && self.suffix == DEFAULT
31    }
32
33    fn parse(input: &str) -> SectionPair<'_> {
34        let input = input.trim_matches(WHITESPACE);
35        match input.split_once(WHITESPACE) {
36            // Something like `[profile name]`
37            Some((prefix, suffix)) => SectionPair {
38                prefix: Some(prefix.trim().into()),
39                suffix: suffix.trim().into(),
40            },
41            // Either `[profile-name]` or `[default]`
42            None => SectionPair {
43                prefix: None,
44                suffix: input.trim().into(),
45            },
46        }
47    }
48
49    /// Validate a SectionKey for a given file key
50    ///
51    /// 1. `name` must ALWAYS be a valid identifier
52    /// 2. For Config files, the profile must either be `default` or it must have a profile prefix
53    /// 3. For credentials files, the profile name MUST NOT have a profile prefix
54    /// 4. Only config files can have sections other than `profile` sections
55    fn valid_for(self, kind: EnvConfigFileKind) -> Result<Self, String> {
56        match kind {
57            EnvConfigFileKind::Config => match (&self.prefix, &self.suffix) {
58                (Some(prefix), suffix) => {
59                    if validate_identifier(suffix).is_ok() {
60                        Ok(self)
61                    } else {
62                        Err(format!("section [{prefix} {suffix}] ignored; `{suffix}` is not a valid identifier"))
63                    }
64                }
65                (None, suffix) => {
66                    if self.is_unprefixed_default() {
67                        Ok(self)
68                    } else {
69                        Err(format!("profile [{suffix}] ignored; sections in the AWS config file (other than [default]) must have a prefix i.e. [profile my-profile]"))
70                    }
71                }
72            },
73            EnvConfigFileKind::Credentials => match (&self.prefix, &self.suffix) {
74                (Some(prefix), suffix) => {
75                    if prefix == PROFILE_PREFIX {
76                        Err(format!("profile `{suffix}` ignored because credential profiles must NOT begin with `profile`"))
77                    } else {
78                        Err(format!("section [{prefix} {suffix}] ignored; config must be in the AWS config file rather than the credentials file"))
79                    }
80                }
81                (None, suffix) => {
82                    if validate_identifier(suffix).is_ok() {
83                        Ok(self)
84                    } else {
85                        Err(format!(
86                            "profile [{suffix}] ignored because `{suffix}` is not a valid identifier",
87                        ))
88                    }
89                }
90            },
91        }
92    }
93}
94
95/// Normalize a raw profile into a `MergedProfile`
96///
97/// This function follows the following rules, codified in the tests & the reference Java implementation
98/// - When the profile is a config file, strip `profile` and trim whitespace (`profile foo` => `foo`)
99/// - Profile names are validated (see `validate_profile_name`)
100/// - A profile named `profile default` takes priority over a profile named `default`.
101/// - Profiles with identical names are merged
102pub(super) fn merge_in(
103    base: &mut EnvConfigSections,
104    raw_profile_set: RawProfileSet<'_>,
105    kind: EnvConfigFileKind,
106) {
107    // parse / validate sections
108    let validated_sections = raw_profile_set
109        .into_iter()
110        .map(|(section_key, properties)| {
111            (SectionPair::parse(section_key).valid_for(kind), properties)
112        });
113
114    // remove invalid profiles & emit a warning
115    // valid_sections contains only valid profiles, but it may contain `[profile default]` and `[default]`
116    // which must be filtered later
117    let valid_sections = validated_sections
118        .filter_map(|(section_key, properties)| match section_key {
119            Ok(section_key) => Some((section_key, properties)),
120            Err(err_str) => {
121                tracing::warn!("{err_str}");
122                None
123            }
124        })
125        .collect::<Vec<_>>();
126    // if a `[profile default]` exists then we should ignore `[default]`
127    let ignore_unprefixed_default = valid_sections
128        .iter()
129        .any(|(section_key, _)| section_key.is_prefixed_default());
130
131    for (section_key, raw_profile) in valid_sections {
132        // When normalizing profiles, profiles should be merged. However, `[profile default]` and
133        // `[default]` are considered two separate profiles. Furthermore, `[profile default]` fully
134        // replaces any contents of `[default]`!
135        if ignore_unprefixed_default && section_key.is_unprefixed_default() {
136            tracing::warn!("profile `[default]` ignored because `[profile default]` was found which takes priority");
137            continue;
138        }
139        let section: &mut dyn Section = match (
140            section_key.prefix.as_deref(),
141            section_key.suffix.as_ref(),
142        ) {
143            (Some(PROFILE_PREFIX), DEFAULT) | (None, DEFAULT) => base
144                .profiles
145                .entry(DEFAULT.to_string())
146                .or_insert_with(|| Profile::new("default", Default::default())),
147            (Some(PROFILE_PREFIX), name) | (None, name) => base
148                .profiles
149                .entry(name.to_string())
150                .or_insert_with(|| Profile::new(name.to_string(), Default::default())),
151            (Some(SSO_SESSION_PREFIX), name) => base
152                .sso_sessions
153                .entry(name.to_string())
154                .or_insert_with(|| SsoSession::new(name.to_string(), Default::default())),
155            (Some(prefix), suffix) => {
156                for (sub_properties_group_name, raw_sub_properties) in &raw_profile {
157                    match validate_identifier(sub_properties_group_name.as_ref())
158                        .map(ToOwned::to_owned)
159                    {
160                        Ok(sub_properties_group_name) => parse_sub_properties(raw_sub_properties)
161                            .for_each(|(sub_property_name, sub_property_value)| {
162                                if let Ok(key) = PropertiesKey::builder()
163                                    .section_key(prefix)
164                                    .section_name(suffix)
165                                    .property_name(&sub_properties_group_name)
166                                    .sub_property_name(sub_property_name)
167                                    .build()
168                                {
169                                    base.other_sections.insert(key, sub_property_value);
170                                }
171                            }),
172                        Err(_) => {
173                            tracing::warn!("`[{prefix} {suffix}].{sub_properties_group_name}` \
174                            ignored because `{sub_properties_group_name}` was not a valid identifier");
175                        }
176                    }
177                }
178
179                continue;
180            }
181        };
182        merge_into_base(section, raw_profile)
183    }
184}
185
186fn merge_into_base(target: &mut dyn Section, profile: HashMap<Cow<'_, str>, Cow<'_, str>>) {
187    for (k, v) in profile {
188        match validate_identifier(k.as_ref()) {
189            Ok(k) => {
190                target.insert(k.to_owned(), Property::new(k.to_owned(), v.into()));
191            }
192            Err(_) => {
193                tracing::warn!(profile = %target.name(), key = ?k, "key ignored because `{k}` was not a valid identifier");
194            }
195        }
196    }
197}
198
199/// Validate that a string is a valid identifier
200///
201/// Identifiers must match `[A-Za-z0-9_\-/.%@:\+]+`
202fn validate_identifier(input: &str) -> Result<&str, ()> {
203    input
204        .chars()
205        .all(|ch| {
206            ch.is_ascii_alphanumeric() || ['_', '-', '/', '.', '%', '@', ':', '+'].contains(&ch)
207        })
208        .then_some(input)
209        .ok_or(())
210}
211
212fn parse_sub_properties(sub_properties_str: &str) -> impl Iterator<Item = (String, String)> + '_ {
213    sub_properties_str
214        .split('\n')
215        .filter(|line| !line.is_empty())
216        .filter_map(|line| {
217            if let Some((key, value)) = line.split_once('=') {
218                let key = key.trim_matches(WHITESPACE).to_owned();
219                let value = value.trim_matches(WHITESPACE).to_owned();
220                Some((key, value))
221            } else {
222                tracing::warn!("`{line}` ignored because it is not a valid sub-property");
223                None
224            }
225        })
226}
227
228#[cfg(test)]
229mod tests {
230    use crate::env_config::file::EnvConfigFileKind;
231    use crate::env_config::normalize::{merge_in, validate_identifier, SectionPair};
232    use crate::env_config::parse::RawProfileSet;
233    use crate::env_config::section::{EnvConfigSections, Section};
234    use std::borrow::Cow;
235    use std::collections::HashMap;
236    use tracing_test::traced_test;
237
238    #[test]
239    fn section_key_parsing() {
240        assert_eq!(
241            SectionPair {
242                prefix: None,
243                suffix: "default".into()
244            },
245            SectionPair::parse("default"),
246        );
247        assert_eq!(
248            SectionPair {
249                prefix: None,
250                suffix: "default".into()
251            },
252            SectionPair::parse("   default "),
253        );
254        assert_eq!(
255            SectionPair {
256                prefix: Some("profile".into()),
257                suffix: "default".into()
258            },
259            SectionPair::parse("profile default"),
260        );
261        assert_eq!(
262            SectionPair {
263                prefix: Some("profile".into()),
264                suffix: "default".into()
265            },
266            SectionPair::parse(" profile   default "),
267        );
268
269        assert_eq!(
270            SectionPair {
271                suffix: "name".into(),
272                prefix: Some("profile".into())
273            },
274            SectionPair::parse("profile name"),
275        );
276        assert_eq!(
277            SectionPair {
278                suffix: "name".into(),
279                prefix: None
280            },
281            SectionPair::parse("name"),
282        );
283        assert_eq!(
284            SectionPair {
285                suffix: "name".into(),
286                prefix: Some("profile".into())
287            },
288            SectionPair::parse("profile\tname"),
289        );
290        assert_eq!(
291            SectionPair {
292                suffix: "name".into(),
293                prefix: Some("profile".into())
294            },
295            SectionPair::parse("profile     name  "),
296        );
297        assert_eq!(
298            SectionPair {
299                suffix: "profilename".into(),
300                prefix: None
301            },
302            SectionPair::parse("profilename"),
303        );
304        assert_eq!(
305            SectionPair {
306                suffix: "whitespace".into(),
307                prefix: None
308            },
309            SectionPair::parse("   whitespace   "),
310        );
311
312        assert_eq!(
313            SectionPair {
314                prefix: Some("sso-session".into()),
315                suffix: "foo".into()
316            },
317            SectionPair::parse("sso-session foo"),
318        );
319        assert_eq!(
320            SectionPair {
321                prefix: Some("sso-session".into()),
322                suffix: "foo".into()
323            },
324            SectionPair::parse("sso-session\tfoo "),
325        );
326        assert_eq!(
327            SectionPair {
328                suffix: "sso-sessionfoo".into(),
329                prefix: None
330            },
331            SectionPair::parse("sso-sessionfoo"),
332        );
333        assert_eq!(
334            SectionPair {
335                suffix: "sso-session".into(),
336                prefix: None
337            },
338            SectionPair::parse("sso-session "),
339        );
340    }
341
342    #[test]
343    fn test_validate_identifier() {
344        assert_eq!(
345            Ok("some-thing:long/the_one%only.foo@bar+"),
346            validate_identifier("some-thing:long/the_one%only.foo@bar+")
347        );
348        assert_eq!(Err(()), validate_identifier("foo!bar"));
349    }
350
351    #[test]
352    #[traced_test]
353    fn ignored_key_generates_warning() {
354        let mut profile: RawProfileSet<'_> = HashMap::new();
355        profile.insert("default", {
356            let mut out = HashMap::new();
357            out.insert(Cow::Borrowed("invalid key"), "value".into());
358            out
359        });
360        let mut base = EnvConfigSections::default();
361        merge_in(&mut base, profile, EnvConfigFileKind::Config);
362        assert!(base
363            .get_profile("default")
364            .expect("contains default profile")
365            .is_empty());
366        assert!(logs_contain(
367            "key ignored because `invalid key` was not a valid identifier"
368        ));
369    }
370
371    #[test]
372    #[traced_test]
373    fn invalid_profile_generates_warning() {
374        let mut profile: RawProfileSet<'_> = HashMap::new();
375        profile.insert("foo", HashMap::new());
376        merge_in(
377            &mut EnvConfigSections::default(),
378            profile,
379            EnvConfigFileKind::Config,
380        );
381        assert!(logs_contain("profile [foo] ignored"));
382    }
383}