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<'a> SectionPair<'a> {
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()
207                || ['_', '-', '/', '.', '%', '@', ':', '+']
208                    .iter()
209                    .any(|c| *c == ch)
210        })
211        .then_some(input)
212        .ok_or(())
213}
214
215fn parse_sub_properties(sub_properties_str: &str) -> impl Iterator<Item = (String, String)> + '_ {
216    sub_properties_str
217        .split('\n')
218        .filter(|line| !line.is_empty())
219        .filter_map(|line| {
220            if let Some((key, value)) = line.split_once('=') {
221                let key = key.trim_matches(WHITESPACE).to_owned();
222                let value = value.trim_matches(WHITESPACE).to_owned();
223                Some((key, value))
224            } else {
225                tracing::warn!("`{line}` ignored because it is not a valid sub-property");
226                None
227            }
228        })
229}
230
231#[cfg(test)]
232mod tests {
233    use crate::env_config::file::EnvConfigFileKind;
234    use crate::env_config::normalize::{merge_in, validate_identifier, SectionPair};
235    use crate::env_config::parse::RawProfileSet;
236    use crate::env_config::section::{EnvConfigSections, Section};
237    use std::borrow::Cow;
238    use std::collections::HashMap;
239    use tracing_test::traced_test;
240
241    #[test]
242    fn section_key_parsing() {
243        assert_eq!(
244            SectionPair {
245                prefix: None,
246                suffix: "default".into()
247            },
248            SectionPair::parse("default"),
249        );
250        assert_eq!(
251            SectionPair {
252                prefix: None,
253                suffix: "default".into()
254            },
255            SectionPair::parse("   default "),
256        );
257        assert_eq!(
258            SectionPair {
259                prefix: Some("profile".into()),
260                suffix: "default".into()
261            },
262            SectionPair::parse("profile default"),
263        );
264        assert_eq!(
265            SectionPair {
266                prefix: Some("profile".into()),
267                suffix: "default".into()
268            },
269            SectionPair::parse(" profile   default "),
270        );
271
272        assert_eq!(
273            SectionPair {
274                suffix: "name".into(),
275                prefix: Some("profile".into())
276            },
277            SectionPair::parse("profile name"),
278        );
279        assert_eq!(
280            SectionPair {
281                suffix: "name".into(),
282                prefix: None
283            },
284            SectionPair::parse("name"),
285        );
286        assert_eq!(
287            SectionPair {
288                suffix: "name".into(),
289                prefix: Some("profile".into())
290            },
291            SectionPair::parse("profile\tname"),
292        );
293        assert_eq!(
294            SectionPair {
295                suffix: "name".into(),
296                prefix: Some("profile".into())
297            },
298            SectionPair::parse("profile     name  "),
299        );
300        assert_eq!(
301            SectionPair {
302                suffix: "profilename".into(),
303                prefix: None
304            },
305            SectionPair::parse("profilename"),
306        );
307        assert_eq!(
308            SectionPair {
309                suffix: "whitespace".into(),
310                prefix: None
311            },
312            SectionPair::parse("   whitespace   "),
313        );
314
315        assert_eq!(
316            SectionPair {
317                prefix: Some("sso-session".into()),
318                suffix: "foo".into()
319            },
320            SectionPair::parse("sso-session foo"),
321        );
322        assert_eq!(
323            SectionPair {
324                prefix: Some("sso-session".into()),
325                suffix: "foo".into()
326            },
327            SectionPair::parse("sso-session\tfoo "),
328        );
329        assert_eq!(
330            SectionPair {
331                suffix: "sso-sessionfoo".into(),
332                prefix: None
333            },
334            SectionPair::parse("sso-sessionfoo"),
335        );
336        assert_eq!(
337            SectionPair {
338                suffix: "sso-session".into(),
339                prefix: None
340            },
341            SectionPair::parse("sso-session "),
342        );
343    }
344
345    #[test]
346    fn test_validate_identifier() {
347        assert_eq!(
348            Ok("some-thing:long/the_one%only.foo@bar+"),
349            validate_identifier("some-thing:long/the_one%only.foo@bar+")
350        );
351        assert_eq!(Err(()), validate_identifier("foo!bar"));
352    }
353
354    #[test]
355    #[traced_test]
356    fn ignored_key_generates_warning() {
357        let mut profile: RawProfileSet<'_> = HashMap::new();
358        profile.insert("default", {
359            let mut out = HashMap::new();
360            out.insert(Cow::Borrowed("invalid key"), "value".into());
361            out
362        });
363        let mut base = EnvConfigSections::default();
364        merge_in(&mut base, profile, EnvConfigFileKind::Config);
365        assert!(base
366            .get_profile("default")
367            .expect("contains default profile")
368            .is_empty());
369        assert!(logs_contain(
370            "key ignored because `invalid key` was not a valid identifier"
371        ));
372    }
373
374    #[test]
375    #[traced_test]
376    fn invalid_profile_generates_warning() {
377        let mut profile: RawProfileSet<'_> = HashMap::new();
378        profile.insert("foo", HashMap::new());
379        merge_in(
380            &mut EnvConfigSections::default(),
381            profile,
382            EnvConfigFileKind::Config,
383        );
384        assert!(logs_contain("profile [foo] ignored"));
385    }
386}