aws_runtime/env_config/
parse.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Profile file parsing
7//!
8//! This file implements profile file parsing at a very literal level. Prior to actually being used,
9//! profiles must be normalized into a canonical form. Constructions that will eventually be
10//! deemed invalid are accepted during parsing such as:
11//! - keys that are invalid identifiers: `a b = c`
12//! - profiles with invalid names
13//! - profile name normalization (`profile foo` => `foo`)
14
15use crate::env_config::source::File;
16use std::borrow::Cow;
17use std::collections::HashMap;
18use std::error::Error;
19use std::fmt::{self, Display, Formatter};
20
21/// A set of profiles that still carries a reference to the underlying data
22pub(super) type RawProfileSet<'a> = HashMap<&'a str, HashMap<Cow<'a, str>, Cow<'a, str>>>;
23
24/// Characters considered to be whitespace by the spec
25///
26/// Profile parsing is actually quite strict about what is and is not whitespace, so use this instead
27/// of `.is_whitespace()` / `.trim()`
28pub(crate) const WHITESPACE: &[char] = &[' ', '\t'];
29const COMMENT: &[char] = &['#', ';'];
30
31/// Location for use during error reporting
32#[derive(Clone, Debug, Eq, PartialEq)]
33struct Location {
34    line_number: usize,
35    path: String,
36}
37
38/// An error encountered while parsing a profile
39#[derive(Debug, Clone)]
40pub struct EnvConfigParseError {
41    /// Location where this error occurred
42    location: Location,
43
44    /// Error message
45    message: String,
46}
47
48impl Display for EnvConfigParseError {
49    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50        write!(
51            f,
52            "error parsing {} on line {}:\n  {}",
53            self.location.path, self.location.line_number, self.message
54        )
55    }
56}
57
58impl Error for EnvConfigParseError {}
59
60/// Validate that a line represents a valid subproperty
61///
62/// - Sub-properties looks like regular properties (`k=v`) that are nested within an existing property.
63/// - Sub-properties must be validated for compatibility with other SDKs, but they are not actually
64///   parsed into structured data.
65fn validate_subproperty(value: &str, location: Location) -> Result<(), EnvConfigParseError> {
66    if value.trim_matches(WHITESPACE).is_empty() {
67        Ok(())
68    } else {
69        parse_property_line(value)
70            .map_err(|err| err.into_error("sub-property", location))
71            .map(|_| ())
72    }
73}
74
75fn is_empty_line(line: &str) -> bool {
76    line.trim_matches(WHITESPACE).is_empty()
77}
78
79fn is_comment_line(line: &str) -> bool {
80    line.starts_with(COMMENT)
81}
82
83/// Parser for profile files
84struct Parser<'a> {
85    /// In-progress profile representation
86    data: RawProfileSet<'a>,
87
88    /// Parser state
89    state: State<'a>,
90
91    /// Parser source location
92    ///
93    /// Location is tracked to facilitate error reporting
94    location: Location,
95}
96
97enum State<'a> {
98    Starting,
99    ReadingProfile {
100        profile: &'a str,
101        property: Option<Cow<'a, str>>,
102        is_subproperty: bool,
103    },
104}
105
106/// Parse `file` into a `RawProfileSet`
107pub(super) fn parse_profile_file(file: &File) -> Result<RawProfileSet<'_>, EnvConfigParseError> {
108    let mut parser = Parser {
109        data: HashMap::new(),
110        state: State::Starting,
111        location: Location {
112            line_number: 0,
113            path: file.path.clone().unwrap_or_default(),
114        },
115    };
116    parser.parse_profile(&file.contents)?;
117    Ok(parser.data)
118}
119
120impl<'a> Parser<'a> {
121    /// Parse `file` containing profile data into `self.data`.
122    fn parse_profile(&mut self, file: &'a str) -> Result<(), EnvConfigParseError> {
123        for (line_number, line) in file.lines().enumerate() {
124            self.location.line_number = line_number + 1; // store a 1-indexed line number
125            if is_empty_line(line) || is_comment_line(line) {
126                continue;
127            }
128            if line.starts_with('[') {
129                self.read_profile_line(line)?;
130            } else if line.starts_with(WHITESPACE) {
131                self.read_property_continuation(line)?;
132            } else {
133                self.read_property_line(line)?;
134            }
135        }
136        Ok(())
137    }
138
139    /// Parse a property line like `a = b`
140    ///
141    /// A property line is only valid when we're within a profile definition, `[profile foo]`
142    fn read_property_line(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
143        let location = &self.location;
144        let (current_profile, name) = match &self.state {
145            State::Starting => return Err(self.make_error("Expected a profile definition")),
146            State::ReadingProfile { profile, .. } => (
147                self.data.get_mut(*profile).expect("profile must exist"),
148                profile,
149            ),
150        };
151        let (k, v) = parse_property_line(line)
152            .map_err(|err| err.into_error("property", location.clone()))?;
153        self.state = State::ReadingProfile {
154            profile: name,
155            property: Some(k.clone()),
156            is_subproperty: v.is_empty(),
157        };
158        current_profile.insert(k, v.into());
159        Ok(())
160    }
161
162    /// Create a location-tagged error message
163    fn make_error(&self, message: &str) -> EnvConfigParseError {
164        EnvConfigParseError {
165            location: self.location.clone(),
166            message: message.into(),
167        }
168    }
169
170    /// Parse the lines of a property after the first line.
171    ///
172    /// This is triggered by lines that start with whitespace.
173    fn read_property_continuation(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
174        let current_property = match &self.state {
175            State::Starting => return Err(self.make_error("Expected a profile definition")),
176            State::ReadingProfile {
177                profile,
178                property: Some(property),
179                is_subproperty,
180            } => {
181                if *is_subproperty {
182                    validate_subproperty(line, self.location.clone())?;
183                }
184                self.data
185                    .get_mut(*profile)
186                    .expect("profile must exist")
187                    .get_mut(property.as_ref())
188                    .expect("property must exist")
189            }
190            State::ReadingProfile {
191                profile: _,
192                property: None,
193                ..
194            } => return Err(self.make_error("Expected a property definition, found continuation")),
195        };
196        let line = line.trim_matches(WHITESPACE);
197        let current_property = current_property.to_mut();
198        current_property.push('\n');
199        current_property.push_str(line);
200        Ok(())
201    }
202
203    fn read_profile_line(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
204        let line = prepare_line(line, false);
205        let profile_name = line
206            .strip_prefix('[')
207            .ok_or_else(|| self.make_error("Profile definition must start with '['"))?
208            .strip_suffix(']')
209            .ok_or_else(|| self.make_error("Profile definition must end with ']'"))?;
210        if !self.data.contains_key(profile_name) {
211            self.data.insert(profile_name, Default::default());
212        }
213        self.state = State::ReadingProfile {
214            profile: profile_name,
215            property: None,
216            is_subproperty: false,
217        };
218        Ok(())
219    }
220}
221
222/// Error encountered while parsing a property
223#[derive(Debug, Eq, PartialEq)]
224enum PropertyError {
225    NoEquals,
226    NoName,
227}
228
229impl PropertyError {
230    fn into_error(self, ctx: &str, location: Location) -> EnvConfigParseError {
231        let mut ctx = ctx.to_string();
232        match self {
233            PropertyError::NoName => {
234                ctx.get_mut(0..1).unwrap().make_ascii_uppercase();
235                EnvConfigParseError {
236                    location,
237                    message: format!("{} did not have a name", ctx),
238                }
239            }
240            PropertyError::NoEquals => EnvConfigParseError {
241                location,
242                message: format!("Expected an '=' sign defining a {}", ctx),
243            },
244        }
245    }
246}
247
248/// Parse a property line into a key-value pair
249fn parse_property_line(line: &str) -> Result<(Cow<'_, str>, &str), PropertyError> {
250    let line = prepare_line(line, true);
251    let (k, v) = line.split_once('=').ok_or(PropertyError::NoEquals)?;
252    let k = k.trim_matches(WHITESPACE);
253    let v = v.trim_matches(WHITESPACE);
254    if k.is_empty() {
255        return Err(PropertyError::NoName);
256    }
257    // We don't want to blindly use `alloc::str::to_ascii_lowercase` because it
258    // always allocates. Instead, we check for uppercase ascii letters. Then,
259    // we only allocate in the case that there ARE letters that need to be
260    // lower-cased.
261    Ok((to_ascii_lowercase(k), v))
262}
263
264pub(crate) fn to_ascii_lowercase(s: &str) -> Cow<'_, str> {
265    if s.bytes().any(|b| b.is_ascii_uppercase()) {
266        Cow::Owned(s.to_ascii_lowercase())
267    } else {
268        Cow::Borrowed(s)
269    }
270}
271
272/// Prepare a line for parsing
273///
274/// Because leading whitespace is significant, this method should only be called after determining
275/// whether a line represents a property (no whitespace) or a sub-property (whitespace).
276/// This function preprocesses a line to simplify parsing:
277/// 1. Strip leading and trailing whitespace
278/// 2. Remove trailing comments
279///
280/// Depending on context, comment characters may need to be preceded by whitespace to be considered
281/// comments.
282fn prepare_line(line: &str, comments_need_whitespace: bool) -> &str {
283    let line = line.trim_matches(WHITESPACE);
284    let mut prev_char_whitespace = false;
285    let mut comment_idx = None;
286    for (idx, chr) in line.char_indices() {
287        if (COMMENT.contains(&chr)) && (prev_char_whitespace || !comments_need_whitespace) {
288            comment_idx = Some(idx);
289            break;
290        }
291        prev_char_whitespace = chr.is_whitespace();
292    }
293    comment_idx
294        .map(|idx| &line[..idx])
295        .unwrap_or(line)
296        // trimming the comment might result in more whitespace that needs to be handled
297        .trim_matches(WHITESPACE)
298}
299
300#[cfg(test)]
301mod test {
302    use super::{parse_profile_file, prepare_line, Location};
303    use crate::env_config::file::EnvConfigFileKind;
304    use crate::env_config::parse::{parse_property_line, PropertyError};
305    use crate::env_config::source::File;
306    use std::borrow::Cow;
307
308    // most test cases covered by the JSON test suite
309
310    #[test]
311    fn property_parsing() {
312        fn ok<'a>(key: &'a str, value: &'a str) -> Result<(Cow<'a, str>, &'a str), PropertyError> {
313            Ok((Cow::Borrowed(key), value))
314        }
315
316        assert_eq!(parse_property_line("a = b"), ok("a", "b"));
317        assert_eq!(parse_property_line("a=b"), ok("a", "b"));
318        assert_eq!(parse_property_line("a = b "), ok("a", "b"));
319        assert_eq!(parse_property_line(" a = b "), ok("a", "b"));
320        assert_eq!(parse_property_line(" a = b 🐱 "), ok("a", "b 🐱"));
321        assert_eq!(parse_property_line("a b"), Err(PropertyError::NoEquals));
322        assert_eq!(parse_property_line("= b"), Err(PropertyError::NoName));
323        assert_eq!(parse_property_line("a =    "), ok("a", ""));
324        assert_eq!(
325            parse_property_line("something_base64=aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg=="),
326            ok("something_base64", "aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg==")
327        );
328
329        assert_eq!(parse_property_line("ABc = DEF"), ok("abc", "DEF"));
330    }
331
332    #[test]
333    fn prepare_line_strips_comments() {
334        assert_eq!(
335            prepare_line("name = value # Comment with # sign", true),
336            "name = value"
337        );
338
339        assert_eq!(
340            prepare_line("name = value#Comment # sign", true),
341            "name = value#Comment"
342        );
343
344        assert_eq!(
345            prepare_line("name = value#Comment # sign", false),
346            "name = value"
347        );
348    }
349
350    #[test]
351    fn error_line_numbers() {
352        let file = File {
353            kind: EnvConfigFileKind::Config,
354            path: Some("~/.aws/config".into()),
355            contents: "[default\nk=v".into(),
356        };
357        let err = parse_profile_file(&file).expect_err("parsing should fail");
358        assert_eq!(err.message, "Profile definition must end with ']'");
359        assert_eq!(
360            err.location,
361            Location {
362                path: "~/.aws/config".into(),
363                line_number: 1
364            }
365        )
366    }
367}