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 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 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() || ['_', '-', '/', '.', '%', '@', ':', '+'].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}