diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java index 42329691..aaf0ad0c 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java @@ -49,6 +49,12 @@ public List getClientPlugins(GenerationContext context) { "A unique and opaque application ID that is appended to the User-Agent header.") .type(Symbol.builder().name("str").build()) .nullable(true) + .useDescriptor(true) + .validator(Symbol.builder() + .name("validate_and_sanitize_ua_string") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) .build(); final String user_agent_plugin_file = "user_agent"; diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py index 41b670e0..3410bc8f 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import re +from string import ascii_letters, digits from typing import Any, get_args from smithy_core.interfaces.retries import RetryStrategy @@ -9,6 +10,8 @@ from smithy_aws_core.config.source_info import SourceInfo +_USERAGENT_ALLOWED_CHARACTERS = ascii_letters + digits + "!#$%&'*+-.^_`|~/" + class ConfigValidationError(ValueError): """Raised when a configuration value fails validation.""" @@ -144,3 +147,30 @@ def validate_retry_strategy( f"retry_strategy must be RetryStrategy or RetryStrategyOptions, got {type(value).__name__}", source, ) + + +def validate_and_sanitize_ua_string( + value: Any, source: SourceInfo | None = None +) -> str | None: + """Validate and sanitize a User-Agent string component. + + Replaces all disallowed characters with a hyphen. Allowed characters are + ASCII alphanumerics and "!#$%&'*+-.^_`|~/". + + :param value: The UA string value to sanitize + :param source: The source that provided this value + + :returns: The sanitized UA string or None if value is None + + :raises ConfigValidationError: If the value is not a string + """ + if value is None: + return None + if not isinstance(value, str): + raise ConfigValidationError( + "sdk_ua_app_id", + value, + f"UA string must be a string, got {type(value).__name__}", + source, + ) + return "".join(c if c in _USERAGENT_ALLOWED_CHARACTERS else "-" for c in value) diff --git a/packages/smithy-aws-core/tests/unit/config/test_property.py b/packages/smithy-aws-core/tests/unit/config/test_property.py index 42ea3129..6fe1c494 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_property.py +++ b/packages/smithy-aws-core/tests/unit/config/test_property.py @@ -96,6 +96,25 @@ def test_different_properties_resolve_independently(self) -> None: assert region == "us-west-2" assert retry_mode == "adaptive" + def test_unresolved_ua_app_id_defaults_to_none(self) -> None: + class ConfigWithNoDefault: + sdk_ua_app_id = ConfigProperty("sdk_ua_app_id") + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithNoDefault(resolver) + + result = config.sdk_ua_app_id + + assert result is None + assert getattr(config, "_cache_sdk_ua_app_id") == ( + None, + SimpleSource("default"), + ) + class TestConfigPropertyValidation: """Test suite for ConfigProperty validation behavior.""" @@ -107,7 +126,6 @@ def _create_config_with_validator( class ConfigWithValidator: region = ConfigProperty("region", validator=validator) - retry_strategy = ConfigProperty("retry_strategy", validator=validator) def __init__(self, resolver: ConfigResolver) -> None: self._resolver = resolver @@ -153,9 +171,7 @@ class ConfigWithComplexResolver: retry_strategy = ConfigProperty( "retry_strategy", resolver_func=mock_resolver, - default_value=RetryStrategyOptions( - retry_mode="standard", max_attempts=3 - ), + default_value=RetryStrategyOptions(retry_mode="standard"), ) def __init__(self, resolver: ConfigResolver) -> None: @@ -171,7 +187,7 @@ def __init__(self, resolver: ConfigResolver) -> None: assert isinstance(result, RetryStrategyOptions) assert result.retry_mode == "standard" - assert result.max_attempts == 3 + assert result.max_attempts is None assert source_info == SimpleSource("default") def test_validator_not_called_on_cached_access(self) -> None: diff --git a/packages/smithy-aws-core/tests/unit/config/test_validators.py b/packages/smithy-aws-core/tests/unit/config/test_validators.py index 546af536..85b03d0b 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_validators.py +++ b/packages/smithy-aws-core/tests/unit/config/test_validators.py @@ -5,6 +5,7 @@ import pytest from smithy_aws_core.config.validators import ( ConfigValidationError, + validate_and_sanitize_ua_string, validate_max_attempts, validate_region, validate_retry_mode, @@ -49,3 +50,27 @@ def test_invalid_retry_mode_error_message(self) -> None: "Invalid value for 'retry_mode': 'random_mode'. retry_mode must be one " "of ('standard',), got random_mode" in str(exc_info.value) ) + + +class TestValidateUaString: + def test_allows_alphanumeric(self) -> None: + assert validate_and_sanitize_ua_string("abc123") == "abc123" + + def test_none_returns_none(self) -> None: + assert validate_and_sanitize_ua_string(None) is None + + def test_allows_spec_special_chars(self) -> None: + assert validate_and_sanitize_ua_string("!#$%&'*+-.^_`|~/") == "!#$%&'*+-.^_`|~/" + + def test_sanitizes_parentheses(self) -> None: + result = validate_and_sanitize_ua_string("Java_HotSpot_(TM)_64-Bit_Server_VM") + assert result == "Java_HotSpot_-TM-_64-Bit_Server_VM" + + def test_empty_string_passthrough(self) -> None: + assert validate_and_sanitize_ua_string("") == "" + + def test_rejects_non_string(self) -> None: + with pytest.raises(ConfigValidationError) as exc_info: + validate_and_sanitize_ua_string(123) + + assert exc_info.value.key == "sdk_ua_app_id"