aws_runtime/env_config/
section.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Sections within an AWS config profile.
7
8use crate::env_config::normalize;
9use crate::env_config::parse::{parse_profile_file, EnvConfigParseError};
10use crate::env_config::property::{Properties, Property};
11use crate::env_config::source::Source;
12use std::borrow::Cow;
13use std::collections::HashMap;
14
15/// Represents a top-level section (e.g., `[profile name]`) in a config file.
16pub(crate) trait Section {
17    /// The name of this section
18    fn name(&self) -> &str;
19
20    /// Returns all the properties in this section
21    #[allow(dead_code)]
22    fn properties(&self) -> &HashMap<String, Property>;
23
24    /// Returns a reference to the property named `name`
25    fn get(&self, name: &str) -> Option<&str>;
26
27    /// True if there are no properties in this section.
28    #[allow(dead_code)]
29    fn is_empty(&self) -> bool;
30
31    /// Insert a property into a section
32    fn insert(&mut self, name: String, value: Property);
33}
34
35#[derive(Debug, Clone, Eq, PartialEq)]
36pub(super) struct SectionInner {
37    pub(super) name: String,
38    pub(super) properties: HashMap<String, Property>,
39}
40
41impl Section for SectionInner {
42    fn name(&self) -> &str {
43        &self.name
44    }
45
46    fn properties(&self) -> &HashMap<String, Property> {
47        &self.properties
48    }
49
50    fn get(&self, name: &str) -> Option<&str> {
51        self.properties
52            .get(name.to_ascii_lowercase().as_str())
53            .map(|prop| prop.value())
54    }
55
56    fn is_empty(&self) -> bool {
57        self.properties.is_empty()
58    }
59
60    fn insert(&mut self, name: String, value: Property) {
61        self.properties.insert(name.to_ascii_lowercase(), value);
62    }
63}
64
65/// An individual configuration profile
66///
67/// An AWS config may be composed of a multiple named profiles within a [`EnvConfigSections`].
68#[derive(Debug, Clone, Eq, PartialEq)]
69pub struct Profile(SectionInner);
70
71impl Profile {
72    /// Create a new profile
73    pub fn new(name: impl Into<String>, properties: HashMap<String, Property>) -> Self {
74        Self(SectionInner {
75            name: name.into(),
76            properties,
77        })
78    }
79
80    /// The name of this profile
81    pub fn name(&self) -> &str {
82        self.0.name()
83    }
84
85    /// Returns a reference to the property named `name`
86    pub fn get(&self, name: &str) -> Option<&str> {
87        self.0.get(name)
88    }
89}
90
91impl Section for Profile {
92    fn name(&self) -> &str {
93        self.0.name()
94    }
95
96    fn properties(&self) -> &HashMap<String, Property> {
97        self.0.properties()
98    }
99
100    fn get(&self, name: &str) -> Option<&str> {
101        self.0.get(name)
102    }
103
104    fn is_empty(&self) -> bool {
105        self.0.is_empty()
106    }
107
108    fn insert(&mut self, name: String, value: Property) {
109        self.0.insert(name, value)
110    }
111}
112
113/// A `[sso-session name]` section in the config.
114#[derive(Debug, Clone, Eq, PartialEq)]
115pub struct SsoSession(SectionInner);
116
117impl SsoSession {
118    /// Create a new SSO session section.
119    pub(super) fn new(name: impl Into<String>, properties: HashMap<String, Property>) -> Self {
120        Self(SectionInner {
121            name: name.into(),
122            properties,
123        })
124    }
125
126    /// Returns a reference to the property named `name`
127    pub fn get(&self, name: &str) -> Option<&str> {
128        self.0.get(name)
129    }
130}
131
132impl Section for SsoSession {
133    fn name(&self) -> &str {
134        self.0.name()
135    }
136
137    fn properties(&self) -> &HashMap<String, Property> {
138        self.0.properties()
139    }
140
141    fn get(&self, name: &str) -> Option<&str> {
142        self.0.get(name)
143    }
144
145    fn is_empty(&self) -> bool {
146        self.0.is_empty()
147    }
148
149    fn insert(&mut self, name: String, value: Property) {
150        self.0.insert(name, value)
151    }
152}
153
154/// A top-level configuration source containing multiple named profiles
155#[derive(Debug, Eq, Clone, PartialEq)]
156pub struct EnvConfigSections {
157    pub(crate) profiles: HashMap<String, Profile>,
158    pub(crate) selected_profile: Cow<'static, str>,
159    pub(crate) sso_sessions: HashMap<String, SsoSession>,
160    pub(crate) other_sections: Properties,
161}
162
163impl Default for EnvConfigSections {
164    fn default() -> Self {
165        Self {
166            profiles: Default::default(),
167            selected_profile: "default".into(),
168            sso_sessions: Default::default(),
169            other_sections: Default::default(),
170        }
171    }
172}
173
174impl EnvConfigSections {
175    /// Create a new Profile set directly from a HashMap
176    ///
177    /// This method creates a ProfileSet directly from a hashmap with no normalization for test purposes.
178    #[cfg(any(test, feature = "test-util"))]
179    pub fn new(
180        profiles: HashMap<String, HashMap<String, String>>,
181        selected_profile: impl Into<Cow<'static, str>>,
182        sso_sessions: HashMap<String, HashMap<String, String>>,
183        other_sections: Properties,
184    ) -> Self {
185        let mut base = EnvConfigSections {
186            selected_profile: selected_profile.into(),
187            ..Default::default()
188        };
189        for (name, profile) in profiles {
190            base.profiles.insert(
191                name.clone(),
192                Profile::new(
193                    name,
194                    profile
195                        .into_iter()
196                        .map(|(k, v)| (k.clone(), Property::new(k, v)))
197                        .collect(),
198                ),
199            );
200        }
201        for (name, session) in sso_sessions {
202            base.sso_sessions.insert(
203                name.clone(),
204                SsoSession::new(
205                    name,
206                    session
207                        .into_iter()
208                        .map(|(k, v)| (k.clone(), Property::new(k, v)))
209                        .collect(),
210                ),
211            );
212        }
213        base.other_sections = other_sections;
214        base
215    }
216
217    /// Retrieves a key-value pair from the currently selected profile
218    pub fn get(&self, key: &str) -> Option<&str> {
219        self.profiles
220            .get(self.selected_profile.as_ref())
221            .and_then(|profile| profile.get(key))
222    }
223
224    /// Retrieves a named profile from the profile set
225    pub fn get_profile(&self, profile_name: &str) -> Option<&Profile> {
226        self.profiles.get(profile_name)
227    }
228
229    /// Returns the name of the currently selected profile
230    pub fn selected_profile(&self) -> &str {
231        self.selected_profile.as_ref()
232    }
233
234    /// Returns true if no profiles are contained in this profile set
235    pub fn is_empty(&self) -> bool {
236        self.profiles.is_empty()
237    }
238
239    /// Returns the names of the profiles in this config
240    pub fn profiles(&self) -> impl Iterator<Item = &str> {
241        self.profiles.keys().map(String::as_ref)
242    }
243
244    /// Returns the names of the SSO sessions in this config
245    pub fn sso_sessions(&self) -> impl Iterator<Item = &str> {
246        self.sso_sessions.keys().map(String::as_ref)
247    }
248
249    /// Retrieves a named SSO session from the config
250    pub fn sso_session(&self, name: &str) -> Option<&SsoSession> {
251        self.sso_sessions.get(name)
252    }
253
254    /// Returns a struct allowing access to other sections in the profile config
255    pub fn other_sections(&self) -> &Properties {
256        &self.other_sections
257    }
258
259    /// Given a [`Source`] of profile config, parse and merge them into a `EnvConfigSections`.
260    pub fn parse(source: Source) -> Result<Self, EnvConfigParseError> {
261        let mut base = EnvConfigSections {
262            selected_profile: source.profile,
263            ..Default::default()
264        };
265
266        for file in source.files {
267            normalize::merge_in(&mut base, parse_profile_file(&file)?, file.kind);
268        }
269        Ok(base)
270    }
271}
272
273#[cfg(test)]
274mod test {
275    use super::EnvConfigSections;
276    use crate::env_config::file::EnvConfigFileKind;
277    use crate::env_config::section::Section;
278    use crate::env_config::source::{File, Source};
279    use arbitrary::{Arbitrary, Unstructured};
280    use serde::Deserialize;
281    use std::collections::HashMap;
282    use std::error::Error;
283    use std::fs;
284    use tracing_test::traced_test;
285
286    /// Run all tests from `test-data/profile-parser-tests.json`
287    ///
288    /// These represent the bulk of the test cases and reach 100% coverage of the parser.
289    #[test]
290    #[traced_test]
291    fn run_tests() -> Result<(), Box<dyn Error>> {
292        let tests = fs::read_to_string("test-data/profile-parser-tests.json")?;
293        let tests: ParserTests = serde_json::from_str(&tests)?;
294        for (i, test) in tests.tests.into_iter().enumerate() {
295            eprintln!("test: {}", i);
296            check(test);
297        }
298        Ok(())
299    }
300
301    #[test]
302    fn empty_source_empty_profile() {
303        let source = make_source(ParserInput {
304            config_file: Some("".to_string()),
305            credentials_file: Some("".to_string()),
306        });
307
308        let profile_set = EnvConfigSections::parse(source).expect("empty profiles are valid");
309        assert!(profile_set.is_empty());
310    }
311
312    #[test]
313    fn profile_names_are_exposed() {
314        let source = make_source(ParserInput {
315            config_file: Some("[profile foo]\n[profile bar]".to_string()),
316            credentials_file: Some("".to_string()),
317        });
318
319        let profile_set = EnvConfigSections::parse(source).expect("profiles loaded");
320
321        let mut profile_names: Vec<_> = profile_set.profiles().collect();
322        profile_names.sort();
323        assert_eq!(profile_names, vec!["bar", "foo"]);
324    }
325
326    /// Run all tests from the fuzzing corpus to validate coverage
327    #[test]
328    #[ignore]
329    fn run_fuzz_tests() -> Result<(), Box<dyn Error>> {
330        let fuzz_corpus = fs::read_dir("fuzz/corpus/profile-parser")?
331            .map(|res| res.map(|entry| entry.path()))
332            .collect::<Result<Vec<_>, _>>()?;
333        for file in fuzz_corpus {
334            let raw = fs::read(file)?;
335            let mut unstructured = Unstructured::new(&raw);
336            let (conf, creds): (Option<&str>, Option<&str>) =
337                Arbitrary::arbitrary(&mut unstructured)?;
338            let profile_source = Source {
339                files: vec![
340                    File {
341                        kind: EnvConfigFileKind::Config,
342                        path: Some("~/.aws/config".to_string()),
343                        contents: conf.unwrap_or_default().to_string(),
344                    },
345                    File {
346                        kind: EnvConfigFileKind::Credentials,
347                        path: Some("~/.aws/credentials".to_string()),
348                        contents: creds.unwrap_or_default().to_string(),
349                    },
350                ],
351                profile: "default".into(),
352            };
353            // don't care if parse fails, just don't panic
354            let _ = EnvConfigSections::parse(profile_source);
355        }
356
357        Ok(())
358    }
359
360    // for test comparison purposes, flatten a profile into a hashmap
361    #[derive(Debug)]
362    struct FlattenedProfileSet {
363        profiles: HashMap<String, HashMap<String, String>>,
364        sso_sessions: HashMap<String, HashMap<String, String>>,
365    }
366    fn flatten(config: EnvConfigSections) -> FlattenedProfileSet {
367        FlattenedProfileSet {
368            profiles: flatten_sections(config.profiles.values().map(|p| p as _)),
369            sso_sessions: flatten_sections(config.sso_sessions.values().map(|s| s as _)),
370        }
371    }
372    fn flatten_sections<'a>(
373        sections: impl Iterator<Item = &'a dyn Section>,
374    ) -> HashMap<String, HashMap<String, String>> {
375        sections
376            .map(|section| {
377                (
378                    section.name().to_string(),
379                    section
380                        .properties()
381                        .values()
382                        .map(|prop| (prop.key().to_owned(), prop.value().to_owned()))
383                        .collect(),
384                )
385            })
386            .collect()
387    }
388
389    fn make_source(input: ParserInput) -> Source {
390        Source {
391            files: vec![
392                File {
393                    kind: EnvConfigFileKind::Config,
394                    path: Some("~/.aws/config".to_string()),
395                    contents: input.config_file.unwrap_or_default(),
396                },
397                File {
398                    kind: EnvConfigFileKind::Credentials,
399                    path: Some("~/.aws/credentials".to_string()),
400                    contents: input.credentials_file.unwrap_or_default(),
401                },
402            ],
403            profile: "default".into(),
404        }
405    }
406
407    // wrapper to generate nicer errors during test failure
408    fn check(test_case: ParserTest) {
409        let copy = test_case.clone();
410        let parsed = EnvConfigSections::parse(make_source(test_case.input));
411        let res = match (parsed.map(flatten), &test_case.output) {
412            (
413                Ok(FlattenedProfileSet {
414                    profiles: actual_profiles,
415                    sso_sessions: actual_sso_sessions,
416                }),
417                ParserOutput::Config {
418                    profiles,
419                    sso_sessions,
420                },
421            ) => {
422                if profiles != &actual_profiles {
423                    Err(format!(
424                        "mismatched profiles:\nExpected: {profiles:#?}\nActual: {actual_profiles:#?}",
425                    ))
426                } else if sso_sessions != &actual_sso_sessions {
427                    Err(format!(
428                        "mismatched sso_sessions:\nExpected: {sso_sessions:#?}\nActual: {actual_sso_sessions:#?}",
429                    ))
430                } else {
431                    Ok(())
432                }
433            }
434            (Err(msg), ParserOutput::ErrorContaining(substr)) => {
435                if format!("{}", msg).contains(substr) {
436                    Ok(())
437                } else {
438                    Err(format!("Expected {} to contain {}", msg, substr))
439                }
440            }
441            (Ok(output), ParserOutput::ErrorContaining(err)) => Err(format!(
442                "expected an error: {err} but parse succeeded:\n{output:#?}",
443            )),
444            (Err(err), ParserOutput::Config { .. }) => {
445                Err(format!("Expected to succeed but got: {}", err))
446            }
447        };
448        if let Err(e) = res {
449            eprintln!("Test case failed: {:#?}", copy);
450            eprintln!("failure: {}", e);
451            panic!("test failed")
452        }
453    }
454
455    #[derive(Deserialize, Debug)]
456    #[serde(rename_all = "camelCase")]
457    struct ParserTests {
458        tests: Vec<ParserTest>,
459    }
460
461    #[derive(Deserialize, Debug, Clone)]
462    #[serde(rename_all = "camelCase")]
463    struct ParserTest {
464        _name: String,
465        input: ParserInput,
466        output: ParserOutput,
467    }
468
469    #[derive(Deserialize, Debug, Clone)]
470    #[serde(rename_all = "camelCase")]
471    enum ParserOutput {
472        Config {
473            profiles: HashMap<String, HashMap<String, String>>,
474            #[serde(default)]
475            sso_sessions: HashMap<String, HashMap<String, String>>,
476        },
477        ErrorContaining(String),
478    }
479
480    #[derive(Deserialize, Debug, Clone)]
481    #[serde(rename_all = "camelCase")]
482    struct ParserInput {
483        config_file: Option<String>,
484        credentials_file: Option<String>,
485    }
486}