1use 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#[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 Some((prefix, suffix)) => SectionPair {
38 prefix: Some(prefix.trim().into()),
39 suffix: suffix.trim().into(),
40 },
41 None => SectionPair {
43 prefix: None,
44 suffix: input.trim().into(),
45 },
46 }
47 }
48
49 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
95pub(super) fn merge_in(
103 base: &mut EnvConfigSections,
104 raw_profile_set: RawProfileSet<'_>,
105 kind: EnvConfigFileKind,
106) {
107 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 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 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 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
199fn 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}