diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py new file mode 100644 index 000000000..a6e42d721 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py @@ -0,0 +1,76 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os + +from smithy_aws_core.config.file_parser import ( + FileType, + parse_config_file, + standardize, +) +from smithy_aws_core.config.parsed_config_file import ParsedConfigFile + +_DEFAULT_CONFIG_FILE = "~/.aws/config" +_DEFAULT_CREDENTIALS_FILE = "~/.aws/credentials" +_CONFIG_FILE_ENV_VAR = "AWS_CONFIG_FILE" +_CREDENTIALS_FILE_ENV_VAR = "AWS_SHARED_CREDENTIALS_FILE" + + +def resolve_config_paths( + config_file_path: str | None = None, + credentials_file_path: str | None = None, +) -> tuple[str, str]: + """Resolve the final config and credentials file paths. + + Resolution order for each path: + 1. Explicit argument (if provided) + 2. Environment variable (AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE) + 3. Default (~/.aws/config / ~/.aws/credentials) + + The ~ is expanded to the user's home directory. + + :param config_file_path: Override path for config file. + :param credentials_file_path: Override path for credentials file. + :returns: Tuple of (resolved_config_path, resolved_credentials_path). + """ + config_path = ( + config_file_path or os.environ.get(_CONFIG_FILE_ENV_VAR) or _DEFAULT_CONFIG_FILE + ) + credentials_path = ( + credentials_file_path + or os.environ.get(_CREDENTIALS_FILE_ENV_VAR) + or _DEFAULT_CREDENTIALS_FILE + ) + + return ( + os.path.expanduser(os.path.expandvars(config_path)), + os.path.expanduser(os.path.expandvars(credentials_path)), + ) + + +async def load_config( + config_file_path: str | None = None, + credentials_file_path: str | None = None, +) -> ParsedConfigFile: + """Load and merge AWS config and credentials files. + + Parses both files, standardizes them, and returns a merged + ParsedConfigFile ready for querying. + + :param config_file_path: Override path for config file. + Defaults to AWS_CONFIG_FILE env var or ~/.aws/config. + :param credentials_file_path: Override path for credentials file. + Defaults to AWS_SHARED_CREDENTIALS_FILE env var or ~/.aws/credentials. + :returns: A ParsedConfigFile with merged profiles from both files. + """ + config_path, credentials_path = resolve_config_paths( + config_file_path, credentials_file_path + ) + + raw_config = await parse_config_file(config_path) + raw_credentials = await parse_config_file(credentials_path) + + std_config = standardize(raw_config, FileType.CONFIG) + std_credentials = standardize(raw_credentials, FileType.CREDENTIALS) + + return ParsedConfigFile(std_config, std_credentials) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py b/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py new file mode 100644 index 000000000..3ad3ceb97 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py @@ -0,0 +1,8 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Config related exceptions""" + + +class ConfigParseError(Exception): + """Raised when a config file cannot be parsed due to invalid syntax.""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py b/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py new file mode 100644 index 000000000..a3ce06b44 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py @@ -0,0 +1,360 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import os +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +from smithy_aws_core.config.exceptions import ConfigParseError + +# Type aliases +type PropertyValue = str | dict[str, str] +type ProfileProperties = dict[str, PropertyValue] +type ProfileMap = dict[str, ProfileProperties] +type RawParsedSections = dict[str, dict[str, str | dict[str, str]]] + +_VALID_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_\-/.%@:+]+$") + + +class FileType(Enum): + CONFIG = "config" + CREDENTIALS = "credentials" + + +@dataclass +class StandardizedOutput: + """Normalized output after standardization.""" + + profiles: ProfileMap = field(default_factory=dict) # type: ignore[assignment] + sso_sessions: ProfileMap = field(default_factory=dict) # type: ignore[assignment] + services: ProfileMap = field(default_factory=dict) # type: ignore[assignment] + + +async def parse_config_file(file_path: str) -> RawParsedSections: + """Parse an AWS config or credentials file. + + Reads the file asynchronously and parses it into raw sections. + + :param file_path: Resolved path to the file. + :returns: Raw sections dict {section_name: {key: value}}. + :raises ConfigParseError: If the file has invalid syntax. + """ + content = await _read_file(file_path) + if content is None: + return {} + return parse_content(content) + + +def standardize( + raw_sections: RawParsedSections, file_type: FileType +) -> StandardizedOutput: + """Standardize raw parsed sections into a normalized profile map. + + Handles: + - Stripping 'profile ' prefix (config files only) + - [default] vs [profile default] precedence + - Invalid profile/property name filtering + - Duplicate profile merging + - Separating profiles, sso-sessions, and services + + :param raw_sections: Raw sections from parse_config_file(). + :param file_type: Whether this is a config or credentials file. + :returns: StandardizedOutput with profiles, sso_sessions, and services. + """ + profiles: ProfileMap = {} + sso_sessions: ProfileMap = {} + services: ProfileMap = {} + + has_profile_default = False + if file_type == FileType.CONFIG: + has_profile_default = any( + _is_profile_prefixed_default(name) for name in raw_sections + ) + + for section_name, properties in raw_sections.items(): + if file_type == FileType.CONFIG: + _classify_config_section( + section_name, + properties, + profiles, + sso_sessions, + services, + has_profile_default, + ) + else: + _classify_credentials_section( + section_name, + properties, + profiles, + ) + + return StandardizedOutput( + profiles=profiles, + sso_sessions=sso_sessions, + services=services, + ) + + +async def _read_file(path: str) -> str | None: + """Read file content asynchronously. + + Returns None if the file doesn't exist or can't be opened. + Per the SEP: inaccessible files are treated as empty. + """ + try: + content = await asyncio.to_thread(Path(path).read_text, encoding="utf-8") + return content + except (FileNotFoundError, PermissionError, OSError, UnicodeDecodeError): + return None + + +def parse_content(content: str) -> RawParsedSections: + """Parse config file content from a string into raw sections. + + Note: This function is public only for direct use by unit tests + In normal config loading, use parse_config_file() which handles file I/O and + calls this internally. + + :param content: The raw config file content as a string. + :returns: Raw sections dict {section_name: {key: value}}. + :raises ConfigParseError: If the content has invalid syntax. + """ + sections: RawParsedSections = {} + current_section: str | None = None + current_key: str | None = None + in_sub_property: bool = False + + for line_num, line in enumerate(content.splitlines(), start=1): + + # Blank line + if line.strip() == "": + continue + + # Continuation line starts with whitespace, and it must be checked + # before comments, because " # foo" is a continuation when it + # follows a property, not a comment. Note that in continuations, # and ; + # are never treated as comments. They're always part of the value. + # Inline comment stripping (where "value # comment" strips the comment) + # only applies to regular property definition lines. + if line[0] in (" ", "\t"): + if current_section is None: + raise ConfigParseError( + f"Line {line_num}: Expected a section definition, " + f"found continuation" + ) + if current_key is None: + raise ConfigParseError( + f"Line {line_num}: Expected a property definition, " + f"found continuation" + ) + + trimmed = line.strip() + + if in_sub_property: + _handle_sub_property( + trimmed, sections, current_section, current_key, line_num + ) + else: + current_value = sections[current_section][current_key] + sections[current_section][current_key] = current_value + "\n" + trimmed # type: ignore[operator] + continue + + # Comment line starts with # or ; + # No whitespace before # or ; + if line.startswith(("#", ";")): + continue + + # Section definition line + if line.lstrip().startswith("["): + section_name = _parse_section_line(line, line_num) + current_section = section_name + current_key = None + in_sub_property = False + if section_name is not None and section_name not in sections: + sections[section_name] = {} + continue + + # Property definition line + if current_section is None: + raise ConfigParseError( + f"Line {line_num}: Expected a section definition, found property" + ) + + key, value = _parse_property_line(line, line_num) + if key is not None: + current_key = key + in_sub_property = value == "" + # We store "" first and later, if an indented 'key = value' line follows, + # _handle_sub_property promotes it from "" to {}. If no continuation, + # it stays as "" + sections[current_section][key] = value + + return sections + + +def _parse_section_line(line: str, line_num: int) -> str | None: + """Parse a section definition line like [profile foo].""" + stripped = line.strip() + bracket_end = stripped.find("]") + if bracket_end == -1: + raise ConfigParseError(f"Line {line_num}: Section definition must end with ']'") + inner = stripped[1:bracket_end].strip() + if not inner: + raise ConfigParseError(f"Line {line_num}: Section name cannot be empty") + return inner + + +def _parse_property_line(line: str, line_num: int) -> tuple[str | None, str]: + """Parse a property definition line like 'key = value'.""" + if "=" not in line: + raise ConfigParseError( + f"Line {line_num}: Expected an '=' sign defining a property" + ) + + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + + if not key: + raise ConfigParseError(f"Line {line_num}: Property did not have a name") + + if not _VALID_IDENTIFIER_RE.match(key): + return None, "" + + key = key.lower() + value = _strip_inline_comment(value) + return key, value + + +def _handle_sub_property( + line: str, + sections: RawParsedSections, + section_name: str, + parent_key: str, + line_num: int, +) -> None: + """Parse a sub-property line (indented key=value under a blank property).""" + # Promote parent from "" to {} as soon as we enter sub-property handling + if not isinstance(sections[section_name][parent_key], dict): + sections[section_name][parent_key] = {} + + if "=" not in line: + raise ConfigParseError( + f"Line {line_num}: Expected an '=' sign defining a property in sub-property" + ) + + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + + if not key: + raise ConfigParseError( + f"Line {line_num}: Property did not have a name in sub-property" + ) + + if not _VALID_IDENTIFIER_RE.match(key): + return + + key = key.lower() + # In sub-properties, # and ; are not treated as inline comments. + # They are included as part of the value + + parent = sections[section_name][parent_key] + parent[key] = value # type: ignore[index] + + +def _strip_inline_comment(value: str) -> str: + # '#' and ';' are comments only if preceded by a whitespace + for i, char in enumerate(value): + if char in ("#", ";") and i > 0 and value[i - 1] in (" ", "\t"): + return value[: i - 1].rstrip() + return value + + +def _classify_config_section( + section_name: str, + properties: dict[str, str | dict[str, str]], + profiles: ProfileMap, + sso_sessions: ProfileMap, + services: ProfileMap, + has_profile_default: bool, +) -> None: + stripped = section_name.strip() + + if stripped.startswith("sso-session"): + name = _extract_prefixed_name(stripped, "sso-session") + if name and _is_valid_identifier(name): + _merge_properties(sso_sessions, name, properties) + return + + if stripped.startswith("services"): + name = _extract_prefixed_name(stripped, "services") + if name and _is_valid_identifier(name): + _merge_properties(services, name, properties) + return + + if stripped == "default": + if not has_profile_default: + _merge_properties(profiles, "default", properties) + return + + if stripped.startswith("profile"): + name = _extract_prefixed_name(stripped, "profile") + if name and _is_valid_identifier(name): + _merge_properties(profiles, name, properties) + return + + +def _classify_credentials_section( + section_name: str, + properties: dict[str, str | dict[str, str]], + profiles: ProfileMap, +) -> None: + stripped = section_name.strip() + + if stripped.startswith("profile"): + return + if stripped.startswith("sso-session"): + return + if stripped.startswith("services"): + return + + if _is_valid_identifier(stripped): + _merge_properties(profiles, stripped, properties) + + +def _extract_prefixed_name(section_name: str, prefix: str) -> str | None: + remainder = section_name[len(prefix) :] + if not remainder or remainder[0] not in (" ", "\t"): + return None + name = remainder.strip() + return name if name else None + + +def _is_valid_identifier(name: str) -> bool: + return bool(_VALID_IDENTIFIER_RE.match(name)) + + +def _is_profile_prefixed_default(section_name: str) -> bool: + """Check if a section is [profile default].""" + stripped = section_name.strip() + if not stripped.startswith("profile"): + return False + remainder = stripped[len("profile") :] + if not remainder or remainder[0] not in (" ", "\t"): + return False + return remainder.strip() == "default" + + +def _merge_properties( + target: ProfileMap, + name: str, + properties: dict[str, str | dict[str, str]], +) -> None: + """Merge properties into target. Later values win for duplicates.""" + if name not in target: + target[name] = {} + target[name].update(properties) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/parsed_config_file.py b/packages/smithy-aws-core/src/smithy_aws_core/config/parsed_config_file.py new file mode 100644 index 000000000..5f79fe9a1 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/parsed_config_file.py @@ -0,0 +1,123 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from smithy_aws_core.config.file_parser import ( + ProfileMap, + ProfileProperties, + StandardizedOutput, +) + + +class ParsedConfigFile: + """A merged representation of AWS config and credentials files. + + Provides lookup access to profile properties after merging both files + with the correct precedence rules (credentials wins for duplicates). + """ + + def __init__( + self, + config_data: StandardizedOutput, + credentials_data: StandardizedOutput, + ): + """Initialize with standardized data from both files. + + :param config_data: Standardized output from the config file. + :param credentials_data: Standardized output from the credentials file. + """ + self._profiles = self._merge_profiles( + config_data.profiles, + credentials_data.profiles, + ) + self._sso_sessions = config_data.sso_sessions + self._services = config_data.services + + def get(self, profile: str, key: str) -> str | None: + """Get a property value for a specific profile. + + :param profile: The profile name to look up. + :param key: The property key (case-insensitive, stored lowercase). + :returns: The property value, or None if not found. + """ + profile_data = self._profiles.get(profile) + if profile_data is None: + return None + value = profile_data.get(key.lower()) + if value is None or isinstance(value, dict): + return None + return value + + def get_sub_property(self, profile: str, key: str, sub_key: str) -> str | None: + """Get a sub-property value for a specific profile. + + For properties like: + s3 = + max_concurrent_requests = 20 + + Usage: get_sub_property("default", "s3", "max_concurrent_requests") + + :param profile: The profile name. + :param key: The parent property key. + :param sub_key: The sub-property key. + :returns: The sub-property value, or None if not found. + """ + profile_data = self._profiles.get(profile) + if profile_data is None: + return None + parent = profile_data.get(key.lower()) + if not isinstance(parent, dict): + return None + return parent.get(sub_key.lower()) + + def get_profile(self, profile: str) -> ProfileProperties | None: + """Get all properties for a profile. + + :param profile: The profile name. + :returns: Dict of properties, or None if profile doesn't exist. + """ + return self._profiles.get(profile) + + def get_sso_session(self, session_name: str) -> ProfileProperties | None: + """Get properties for an SSO session. + + :param session_name: The SSO session name. + :returns: Dict of properties, or None if session doesn't exist. + """ + return self._sso_sessions.get(session_name) + + @property + def profiles(self) -> ProfileMap: + """All merged profiles.""" + return self._profiles + + @property + def sso_sessions(self) -> ProfileMap: + """All SSO sessions from config file.""" + return self._sso_sessions + + @property + def services(self) -> ProfileMap: + """All services sections from config file.""" + return self._services + + @staticmethod + def _merge_profiles( + config_profiles: ProfileMap, + credentials_profiles: ProfileMap, + ) -> ProfileMap: + """Merge profiles from config and credentials files. + + Properties in Credentials file take precedence for duplicates. + """ + merged: ProfileMap = {} + + for name, props in config_profiles.items(): + merged[name] = dict(props) + + for name, props in credentials_profiles.items(): + if name in merged: + merged[name].update(props) + else: + merged[name] = dict(props) + + return merged diff --git a/packages/smithy-aws-core/tests/unit/config/test-data/config-file-location-tests.json b/packages/smithy-aws-core/tests/unit/config/test-data/config-file-location-tests.json new file mode 100644 index 000000000..2029792d8 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test-data/config-file-location-tests.json @@ -0,0 +1,135 @@ +{ + "description": "These are test descriptions that specify which files and profiles should be loaded based on the specified environment variables.", + + "tests": [ + { + "name": "User home is loaded from $HOME with highest priority on non-windows platforms.", + "environment": { + "HOME": "/home/user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on non-windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "/home/user", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded from $HOME with highest priority on windows platforms.", + "environment": { + "HOME": "C:\\users\\user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $USERPROFILE on windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "C:\\users\\user", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $HOMEDRIVE$HOMEPATH on windows platforms when $HOME and $USERPROFILE are not set.", + "environment": { + "HOMEDRIVE": "C:", + "HOMEPATH": "\\users\\user" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on windows platforms when no environment variables are set.", + "environment": { + }, + "languageSpecificHome": "C:\\users\\user", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default config location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "/other/path/config", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/other/path/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "/other/path/credentials", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/other/path/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "C:\\other\\path\\config", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\other\\path\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "C:\\other\\path\\credentials", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\other\\path\\credentials" + } + ] +} \ No newline at end of file diff --git a/packages/smithy-aws-core/tests/unit/config/test-data/config-file-parser-tests.json b/packages/smithy-aws-core/tests/unit/config/test-data/config-file-parser-tests.json new file mode 100644 index 000000000..686c251a9 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test-data/config-file-parser-tests.json @@ -0,0 +1,1571 @@ +{ + "description": "These are test descriptions that describe how to convert a raw configuration and credentials file into an in-memory representation of the config file for profiles and sso-sessions.", + + "tests": [ + { + "name": "Empty files have no profiles.", + "input": { + "configFile" : "" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Empty profiles have no properties.", + "input": { + "configFile": "[profile foo]\n" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Profile definitions must end with brackets.", + "input": { + "configFile": "[profile foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "Profile names should be trimmed.", + "input": { + "configFile": "[profile \tfoo \t]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate profile names from the section.", + "input": { + "configFile": "[profile\tfoo]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Properties must be defined in a section.", + "input": { + "configFile": "name = value" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Profiles can contain properties.", + "input": { + "configFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[profile foo]\r\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = val=ue" + }, + "output": { + "profiles": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = 😂" + }, + "output": { + "profiles": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "Profiles can contain multiple properties.", + "input": { + "configFile": "[profile foo]\nname = value\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[profile foo]\nname \t= \tvalue \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[profile foo]\nname =" + }, + "output": { + "profiles": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[profile foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[profile foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple profiles can be empty.", + "input": { + "configFile": "[profile foo]\n[profile bar]" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple profiles can have properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile bar]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[profile foo]\n\t\n \nname = value\n\t \n[profile bar]\n \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[profile foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[profile foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[profile foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[profile foo];\nname = value ;\n" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to profile names.", + "input": { + "configFile": "[profile foo]; Adjacent semicolons\n[profile bar]# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[profile foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[profile foo]\nname = value\n \t -continued \t " + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued # Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued ; Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a profile.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with profile definitions.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate profiles in the same file merge properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in a profile use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is first.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is last.", + "input": { + "configFile": "[default]\nname2 = value2\n[profile default]\nname = value" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Invalid profile names are ignored.", + "input": { + "configFile": "[profile in valid]\nname = value", + "credentialsFile": "[in valid 2]\nname2 = value2" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[profile foo]\nin valid = value" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[profile ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "profiles": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[profile foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "profiles": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[profile foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name =" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value # Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[profile foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[profile foo]\ns3 =\n in valid = value" + }, + "output": { + "profiles": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Profiles duplicated in multiple files are merged.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes in the config file ignore the one without prefix when merging.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes merge with credentials", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3", + "credentialsFile": "[default]\nsecret=foo" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3", + "secret": "foo" + } + } + } + }, + + { + "name": "Duplicate properties between files uses credentials property.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Credentials profiles with prefix are ignored.", + "input": { + "credentialsFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Comment characters adjacent to profile decls", + "input": { + "configFile": "[profile foo]; semicolon\n[profile bar]# pound" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `profile` is invalid", + "input": { + "configFile": "[profilefoo]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {} + } + } + }, + + { + "name": "profile name with extra whitespace", + "input": { + "configFile": "[ profile foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "profile name with extra whitespace in credentials", + "input": { + "credentialsFile": "[ foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid profile name are ignored", + "input": { + "configFile": "[profile foo]\nname = value\n[profile in valid]\nx = 1\n[profile bar]\nname = value2" + }, + "output": { + "profiles": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined (case insensitive).", + "input": { + "configFile": "[profile foo]\nName = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Empty files have no sso sessions.", + "input": { + "configFile": "" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Empty sso sessions have no properties.", + "input": { + "configFile": "[sso-session foo]\n" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions without a name are ignored.", + "input": { + "configFile": "[sso-session]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "sso-session definitions must end with brackets.", + "input": { + "configFile": "[sso-session foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "sso-session names should be trimmed.", + "input": { + "configFile": "[sso-session \tfoo \t]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate sso-session names from the section.", + "input": { + "configFile": "[sso-session\tfoo]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions can contain properties.", + "input": { + "configFile": "[sso-session foo]\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[sso-session foo]\r\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = val=ue" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = 😂" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "sso-sessions can contain multiple properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname \t= \tvalue \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[sso-session foo]\nname =" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[sso-session foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[sso-session foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple sso-sessions can be empty.", + "input": { + "configFile": "[sso-session foo]\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple sso-sessions can have properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session bar]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[sso-session foo]\n\t\n \nname = value\n\t \n[sso-session bar]\n \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[sso-session foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[sso-session foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[sso-session foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[sso-session foo];\nname = value ;\n" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to sso-session names.", + "input": { + "configFile": "[sso-session foo]; Adjacent semicolons\n[sso-session bar]# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[sso-session foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname = value\n \t -continued \t " + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a sso-session.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with sso-session definitions.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate sso-sessions in the same file merge properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in an sso-session use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Invalid sso-session names are ignored.", + "input": { + "configFile": "[sso-session in valid]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[sso-session foo]\nin valid = value" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[sso-session ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "ssoSessions": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[sso-session foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "ssoSessions": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name =" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n in valid = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Comment characters adjacent to sso-session decls", + "input": { + "configFile": "[sso-session foo]; semicolon\n[sso-session bar]# pound" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `sso-session` is invalid", + "input": { + "configFile": "[sso-sessionfoo]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {} + } + } + }, + + { + "name": "sso-session name with extra whitespace", + "input": { + "configFile": "[ sso-session foo ]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid sso-session name are ignored", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session in valid]\nx = 1\n[sso-session bar]\nname = value2" + }, + "output": { + "ssoSessions": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined (case insensitive).", + "input": { + "configFile": "[sso-session foo]\nName = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "sso-sessions in the credentials file are ignored.", + "input": { + "credentialsFile": "[sso-session foo]\nName = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Profile and sso-session can share names.", + "input": { + "configFile": "[profile foo]\nname = value\n[sso-session foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + }, + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + } + ] +} diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py new file mode 100644 index 000000000..a4180eff9 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py @@ -0,0 +1,99 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from pathlib import Path + +import pytest +from smithy_aws_core.config import load_config +from smithy_aws_core.config.file_parser import parse_config_file + + +class TestFileIssues: + """Tests that missing files are treated as empty.""" + + @pytest.mark.asyncio + async def test_config_not_found_returns_empty(self, tmp_path: Path): + nonexistent = str(tmp_path / "does_not_exist") + result = await parse_config_file(nonexistent) + assert result == {} + + @pytest.mark.asyncio + async def test_load_config_with_missing_files(self, tmp_path: Path): + config_file = await load_config( + config_file_path=str(tmp_path / "no_config"), + credentials_file_path=str(tmp_path / "no_credentials"), + ) + assert config_file.profiles == {} + assert config_file.sso_sessions == {} + assert config_file.services == {} + + @pytest.mark.asyncio + async def test_permission_denied_returns_empty(self, tmp_path: Path): + restricted_file = tmp_path / "restricted_config" + restricted_file.write_text("[profile default]\nregion = us-east-1\n") + restricted_file.chmod(0o000) + try: + result = await parse_config_file(str(restricted_file)) + assert result == {} + finally: + restricted_file.chmod(0o644) + + +class TestEncodingErrors: + """Tests for files with invalid encoding.""" + + @pytest.mark.asyncio + async def test_bad_unicode_returns_empty(self, tmp_path: Path): + bad_file = tmp_path / "bad_config" + bad_file.write_bytes(b"[default]\nregion = \xff\xfe invalid") + result = await parse_config_file(str(bad_file)) + assert result == {} + + +class TestMultiFileMerge: + """Tests for merging config and credentials files with precedence.""" + + @pytest.mark.asyncio + async def test_credentials_override_config(self, tmp_path: Path): + config = tmp_path / "config" + config.write_text( + "[profile default]\nregion = us-east-1\naws_access_key_id = CONFIG_KEY\n" + ) + credentials = tmp_path / "credentials" + credentials.write_text( + "[default]\n" + "aws_access_key_id = CREDS_KEY\n" + "aws_secret_access_key = CREDS_SECRET\n" + ) + + result = await load_config( + config_file_path=str(config), + credentials_file_path=str(credentials), + ) + + assert result.get("default", "aws_access_key_id") == "CREDS_KEY" + assert result.get("default", "region") == "us-east-1" + assert result.get("default", "aws_secret_access_key") == "CREDS_SECRET" + + @pytest.mark.asyncio + async def test_missing_credentials_file_still_loads_config(self, tmp_path: Path): + config = tmp_path / "config" + config.write_text("[profile work]\nregion = us-west-2\n") + + result = await load_config( + config_file_path=str(config), + credentials_file_path=str(tmp_path / "nonexistent"), + ) + assert result.get("work", "region") == "us-west-2" + + @pytest.mark.asyncio + async def test_missing_config_file_still_loads_credentials(self, tmp_path: Path): + credentials = tmp_path / "credentials" + credentials.write_text("[default]\naws_access_key_id = KEY\n") + + result = await load_config( + config_file_path=str(tmp_path / "nonexistent"), + credentials_file_path=str(credentials), + ) + assert result.get("default", "aws_access_key_id") == "KEY" diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py new file mode 100644 index 000000000..f47f9ed13 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py @@ -0,0 +1,59 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json +import platform as platform_mod +from pathlib import Path +from typing import cast +from unittest.mock import patch + +import pytest +from smithy_aws_core.config import resolve_config_paths + +_LOCATION_TESTS_FILE = ( + Path(__file__).parent / "test-data" / "config-file-location-tests.json" +) + +with open(_LOCATION_TESTS_FILE) as f: + _LOCATION_TESTS = json.load(f)["tests"] + + +@pytest.mark.parametrize( + "test_case", + _LOCATION_TESTS, + ids=lambda t: t["name"], +) +def test_config_file_location(test_case: dict[str, object]): + """Validate config/credentials file location based on the test cases + in config-file-location-tests.json file""" + test_platform = cast(str, test_case.get("platform", "linux")) + + # Skip Windows-specific tests on non-Windows machines and vice versa + if test_platform == "windows" and platform_mod.system() != "Windows": + pytest.skip("Windows-specific test, skipping on non-Windows platform") + if test_platform == "linux" and platform_mod.system() == "Windows": + pytest.skip("Linux-specific test, skipping on Windows platform") + + environment = cast(dict[str, str], test_case.get("environment", {})) + expected_config = cast(str, test_case["configLocation"]) + expected_credentials = cast(str, test_case["credentialsLocation"]) + + # Build the environment: only include keys that are actually "set" + env_vars: dict[str, str] = {k: v for k, v in environment.items() if v != "ignored"} + + language_home = cast(str, test_case.get("languageSpecificHome")) + if language_home and language_home != "ignored" and "HOME" not in env_vars: + env_vars["HOME"] = language_home + + # Test resolve_config_paths() with the mocked environment + with patch.dict("os.environ", env_vars, clear=True): + resolved_config, resolved_credentials = resolve_config_paths() + + assert resolved_config == expected_config, ( + f"Config location mismatch: got '{resolved_config}', " + f"expected '{expected_config}'" + ) + assert resolved_credentials == expected_credentials, ( + f"Credentials location mismatch: got '{resolved_credentials}', " + f"expected '{expected_credentials}'" + ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py new file mode 100644 index 000000000..f89599b7c --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py @@ -0,0 +1,93 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json +from pathlib import Path +from typing import cast + +import pytest +from smithy_aws_core.config.exceptions import ConfigParseError +from smithy_aws_core.config.file_parser import ( + FileType, + parse_content, + standardize, +) +from smithy_aws_core.config.parsed_config_file import ParsedConfigFile + +_PARSER_TESTS_FILE = ( + Path(__file__).parent / "test-data" / "config-file-parser-tests.json" +) + +with open(_PARSER_TESTS_FILE) as f: + _PARSER_TESTS = json.load(f)["tests"] + + +def _run_parse_and_standardize( + config_content: str | None = None, + credentials_content: str | None = None, +) -> dict[str, object]: + """Parse and standardize config/credentials content, return merged output. + + Returns a dict with 'profiles' and 'ssoSessions' keys matching the + JSON test case expected output format. + """ + raw_config = parse_content(config_content) if config_content is not None else {} + raw_credentials = ( + parse_content(credentials_content) if credentials_content is not None else {} + ) + + std_config = standardize(raw_config, FileType.CONFIG) + std_credentials = standardize(raw_credentials, FileType.CREDENTIALS) + + config_file = ParsedConfigFile(std_config, std_credentials) + + result: dict[str, object] = {} + if config_file.profiles: + result["profiles"] = config_file.profiles + else: + result["profiles"] = {} + + if config_file.sso_sessions: + result["ssoSessions"] = config_file.sso_sessions + + return result + + +@pytest.mark.parametrize( + "test_case", + _PARSER_TESTS, + ids=lambda t: t["name"], +) +def test_config_file_parser_conformance(test_case: dict[str, object]): + """Validate config file parsing against SEP conformance test cases.""" + input_data = cast(dict[str, str], test_case["input"]) + expected_output = cast(dict[str, object], test_case["output"]) + + config_content = input_data.get("configFile") + credentials_content = input_data.get("credentialsFile") + + # Error case + if "errorContaining" in expected_output: + expected_error = cast(str, expected_output["errorContaining"]) + with pytest.raises(ConfigParseError, match=expected_error): + _run_parse_and_standardize(config_content, credentials_content) + return + + # Success case + actual_output: dict[str, object] = _run_parse_and_standardize( + config_content, credentials_content + ) + + if "profiles" in expected_output: + assert actual_output.get("profiles", {}) == expected_output["profiles"], ( + f"Profiles mismatch.\n" + f"Expected: {json.dumps(expected_output['profiles'], indent=2)}\n" + f"Actual: {json.dumps(actual_output.get('profiles', {}), indent=2)}" + ) + + if "ssoSessions" in expected_output: + assert actual_output.get("ssoSessions", {}) == expected_output["ssoSessions"], ( + f"SSO sessions mismatch.\n" + f"Expected: {json.dumps(expected_output['ssoSessions'], indent=2)}\n" + f"Actual: {json.dumps(actual_output.get('ssoSessions', {}), indent=2)}" + ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_parsed_config_file.py b/packages/smithy-aws-core/tests/unit/config/test_parsed_config_file.py new file mode 100644 index 000000000..fefa8e849 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_parsed_config_file.py @@ -0,0 +1,183 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for ParsedConfigFile class.""" + +from smithy_aws_core.config.file_parser import ProfileMap, StandardizedOutput +from smithy_aws_core.config.parsed_config_file import ParsedConfigFile + + +def _make_config_file( + config_profiles: ProfileMap | None = None, + config_sso_sessions: ProfileMap | None = None, + config_services: ProfileMap | None = None, + credentials_profiles: ProfileMap | None = None, +) -> ParsedConfigFile: + """Helper to build a ParsedConfigFile from raw dicts.""" + config_data = StandardizedOutput( + profiles=config_profiles or {}, + sso_sessions=config_sso_sessions or {}, + services=config_services or {}, + ) + credentials_data = StandardizedOutput( + profiles=credentials_profiles or {}, + ) + return ParsedConfigFile(config_data, credentials_data) + + +class TestGet: + """Tests for ParsedConfigFile.get()""" + + def test_returns_value_for_existing_profile_and_key(self): + cf = _make_config_file(config_profiles={"default": {"region": "us-east-1"}}) + assert cf.get("default", "region") == "us-east-1" + + def test_returns_none_for_missing_profile(self): + cf = _make_config_file(config_profiles={"default": {"region": "us-east-1"}}) + assert cf.get("nonexistent", "region") is None + + def test_returns_none_for_missing_key(self): + cf = _make_config_file(config_profiles={"default": {"region": "us-east-1"}}) + assert cf.get("default", "output") is None + + def test_is_case_insensitive_for_keys(self): + cf = _make_config_file(config_profiles={"default": {"region": "us-east-1"}}) + assert cf.get("default", "REGION") == "us-east-1" + assert cf.get("default", "Region") == "us-east-1" + + def test_returns_none_for_sub_property_key(self): + """get() should return None if the value is a dict (sub-property). + We should use get_sub_property instead.""" + cf = _make_config_file( + config_profiles={"default": {"s3": {"max_concurrent_requests": "20"}}} + ) + assert cf.get("default", "s3") is None + + +class TestGetSubProperty: + """Tests for ParsedConfigFile.get_sub_property()""" + + def test_returns_value(self): + cf = _make_config_file( + config_profiles={ + "default": { + "s3": {"max_concurrent_requests": "20", "addressing_style": "path"} + } + } + ) + assert cf.get_sub_property("default", "s3", "max_concurrent_requests") == "20" + assert cf.get_sub_property("default", "s3", "addressing_style") == "path" + + def test_returns_none_for_missing_sub_key(self): + cf = _make_config_file( + config_profiles={"default": {"s3": {"max_concurrent_requests": "20"}}} + ) + assert cf.get_sub_property("default", "s3", "nonexistent") is None + + def test_returns_none_for_scalar_value(self): + """If the parent key is a string (not a dict), return None.""" + cf = _make_config_file(config_profiles={"default": {"region": "us-east-1"}}) + assert cf.get_sub_property("default", "region", "anything") is None + + def test_returns_none_for_missing_profile(self): + cf = _make_config_file(config_profiles={}) + assert cf.get_sub_property("default", "s3", "anything") is None + + +class TestGetProfile: + """Tests for ParsedConfigFile.get_profile()""" + + def test_returns_all_properties(self): + cf = _make_config_file( + config_profiles={"work": {"region": "us-west-2", "output": "json"}} + ) + assert cf.get_profile("work") == {"region": "us-west-2", "output": "json"} + + def test_returns_none_for_missing(self): + cf = _make_config_file(config_profiles={}) + assert cf.get_profile("nonexistent") is None + + +class TestMerge: + """Tests for credentials/config merge behavior.""" + + def test_credentials_override_config_for_duplicate_key(self): + """When same key exists in both files, credentials wins.""" + cf = _make_config_file( + config_profiles={ + "default": { + "aws_access_key_id": "CONFIG_KEY_ONE", + "region": "us-east-1", + } + }, + credentials_profiles={"default": {"aws_access_key_id": "CONFIG_KEY_TWO"}}, + ) + assert cf.get("default", "aws_access_key_id") == "CONFIG_KEY_TWO" + assert cf.get("default", "region") == "us-east-1" + + def test_profiles_from_both_files_are_merged(self): + cf = _make_config_file( + config_profiles={"config_only": {"region": "us-east-1"}}, + credentials_profiles={"creds_only": {"aws_access_key_id": "KEY"}}, + ) + assert cf.get("config_only", "region") == "us-east-1" + assert cf.get("creds_only", "aws_access_key_id") == "KEY" + + def test_properties_merged_within_same_profile(self): + cf = _make_config_file( + config_profiles={"default": {"region": "us-east-1", "output": "json"}}, + credentials_profiles={ + "default": { + "aws_access_key_id": "KEY", + "aws_secret_access_key": "SECRET", + } + }, + ) + profile = cf.get_profile("default") + assert profile == { + "region": "us-east-1", + "output": "json", + "aws_access_key_id": "KEY", + "aws_secret_access_key": "SECRET", + } + + +class TestSsoSessions: + """Tests for SSO session access.""" + + def test_get_sso_session_returns_properties(self): + cf = _make_config_file( + config_sso_sessions={ + "my-session": { + "sso_start_url": "https://example.com", + "sso_region": "us-east-1", + } + } + ) + assert cf.get_sso_session("my-session") == { + "sso_start_url": "https://example.com", + "sso_region": "us-east-1", + } + + def test_get_sso_session_returns_none_for_missing(self): + cf = _make_config_file() + assert cf.get_sso_session("nonexistent") is None + + +class TestProperties: + """Tests for read-only property accessors.""" + + def test_profiles_property_returns_all(self): + cf = _make_config_file( + config_profiles={"a": {"x": "1"}, "b": {"y": "2"}}, + ) + assert cf.profiles == {"a": {"x": "1"}, "b": {"y": "2"}} + + def test_sso_sessions_property(self): + cf = _make_config_file(config_sso_sessions={"sess": {"url": "https://x"}}) + assert cf.sso_sessions == {"sess": {"url": "https://x"}} + + def test_services_property(self): + cf = _make_config_file( + config_services={"my-svc": {"endpoint_url": "http://localhost"}} + ) + assert cf.services == {"my-svc": {"endpoint_url": "http://localhost"}}