From 1c2db125162761991d1cde588eb31452f37541fa Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 10 Nov 2025 09:54:23 -0500 Subject: [PATCH 01/15] Add simple process credentials resolver --- .../src/smithy_aws_core/identity/__init__.py | 3 + .../src/smithy_aws_core/identity/process.py | 99 ++++++ .../tests/unit/identity/test_process.py | 307 ++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/process.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/test_process.py diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index 7db4add9c..f48aee065 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -18,6 +18,7 @@ from .container import ContainerCredentialsResolver from .environment import EnvironmentCredentialsResolver from .imds import IMDSCredentialsResolver +from .process import ProcessCredentialsConfig, ProcessCredentialsResolver from .static import StaticCredentialsResolver __all__ = ( @@ -32,6 +33,8 @@ "IdentityChainError", "ProfileSessionCredentialsProvider", "ProfileStaticCredentialsProvider", + "ProcessCredentialsConfig", + "ProcessCredentialsResolver", "SharedConfigProvider", "StaticCredentialsResolver", "UnclaimedSource", diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py new file mode 100644 index 000000000..ae685a01f --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -0,0 +1,99 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import asyncio +import json +from dataclasses import dataclass +from datetime import UTC, datetime + +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.exceptions import SmithyIdentityError + +from smithy_aws_core.identity.components import ( + AWSCredentialsIdentity, + AWSIdentityProperties, +) + +_DEFAULT_TIMEOUT = 30 + + +@dataclass +class ProcessCredentialsConfig: + """Configuration for process credential retrieval operations.""" + + timeout: int = _DEFAULT_TIMEOUT + + +class ProcessCredentialsResolver( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] +): + """Resolves AWS Credentials from a process.""" + + def __init__( + self, + command: list[str], + config: ProcessCredentialsConfig | None = None, + ): + if not command: + raise ValueError("command must be a non-empty list") + self._command = command + self._config = config or ProcessCredentialsConfig() + self._credentials = None + + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: + if self._credentials is not None: + # Long-term credentials (no expiration) should always be reused + if self._credentials.expiration is None: + return self._credentials + # Temporary credentials should be reused if not expired + if datetime.now(UTC) < self._credentials.expiration: + return self._credentials + + try: + process = await asyncio.create_subprocess_exec( + *self._command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=self._config.timeout + ) + except TimeoutError as e: + raise SmithyIdentityError( + f"Credential process timed out after {self._config.timeout} seconds" + ) from e + + if process.returncode != 0: + raise SmithyIdentityError( + f"Credential process failed with non-zero exit code: {stderr.decode('utf-8')}" + ) + creds = json.loads(stdout.decode("utf-8")) + + version = creds.get("Version") + if version is None or version != 1: + raise SmithyIdentityError( + f"Unsupported version '{version}' for credential process provider, supported versions: 1" + ) + access_key_id = creds.get("AccessKeyId") + secret_access_key = creds.get("SecretAccessKey") + session_token = creds.get("SessionToken") + expiration = creds.get("Expiration") + account_id = creds.get("AccountId") + + if isinstance(expiration, str): + expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC) + + if access_key_id is None or secret_access_key is None: + raise SmithyIdentityError( + "AccessKeyId and SecretAccessKey are required for process credentials" + ) + + self._credentials = AWSCredentialsIdentity( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + session_token=session_token, + expiration=expiration, + account_id=account_id, + ) + return self._credentials diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py new file mode 100644 index 000000000..f9fd9e597 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -0,0 +1,307 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import asyncio +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, patch + +import pytest +from smithy_aws_core.identity.process import ( + ProcessCredentialsConfig, + ProcessCredentialsResolver, +) +from smithy_core.exceptions import SmithyIdentityError + +ISO8601 = "%Y-%m-%dT%H:%M:%SZ" + +DEFAULT_RESPONSE_DATA = { + "Version": 1, + "AccessKeyId": "akid123", + "SecretAccessKey": "s3cr3t", + "SessionToken": "session_token", +} + + +def test_config_default_values(): + config = ProcessCredentialsConfig() + assert config.timeout == 30 + + +def test_config_custom_values(): + config = ProcessCredentialsConfig(timeout=60) + assert config.timeout == 60 + + +def test_resolver_empty_command(): + with pytest.raises(ValueError, match="command must be a non-empty list"): + ProcessCredentialsResolver([]) + + +def test_resolver_none_command(): + with pytest.raises(ValueError, match="command must be a non-empty list"): + ProcessCredentialsResolver(None) # type: ignore[arg-type] + + +def mock_subprocess(returncode: int, stdout: bytes, stderr: bytes = b""): + """Helper to mock asyncio.create_subprocess_exec""" + process = AsyncMock() + process.returncode = returncode + process.communicate.return_value = (stdout, stderr) + return process + + +@pytest.mark.asyncio +async def test_valid_credentials_with_session_token(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == "akid123" + assert identity.secret_access_key == "s3cr3t" + assert identity.session_token == "session_token" + assert identity.expiration is None + assert identity.account_id is None + + +@pytest.mark.asyncio +async def test_valid_credentials_without_session_token(): + resp_data = { + "Version": 1, + "AccessKeyId": "akid456", + "SecretAccessKey": "s3cr3t456", + } + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == "akid456" + assert identity.secret_access_key == "s3cr3t456" + assert identity.session_token is None + + +@pytest.mark.asyncio +async def test_credentials_with_expiration(): + current_time = datetime.now(UTC) + timedelta(minutes=10) + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = current_time.strftime(ISO8601) + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.expiration is not None + assert identity.expiration.tzinfo == UTC + + +@pytest.mark.asyncio +async def test_credentials_with_account_id(): + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["AccountId"] = "123456789012" + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.account_id == "123456789012" + + +@pytest.mark.asyncio +async def test_non_zero_exit_code(): + process = mock_subprocess(1, b"", b"Process error message") + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises(SmithyIdentityError, match="non-zero exit code"): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_missing_access_key_id(): + resp_data = { + "Version": 1, + "SecretAccessKey": "s3cr3t", + } + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises( + SmithyIdentityError, + match="AccessKeyId and SecretAccessKey are required", + ): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_missing_secret_access_key(): + resp_data = { + "Version": 1, + "AccessKeyId": "akid123", + } + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises( + SmithyIdentityError, + match="AccessKeyId and SecretAccessKey are required", + ): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_invalid_version(): + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Version"] = 2 + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises(SmithyIdentityError, match="Unsupported version '2'"): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_missing_version(): + resp_data = { + "AccessKeyId": "akid123", + "SecretAccessKey": "s3cr3t", + } + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises(SmithyIdentityError, match="Unsupported version 'None'"): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_invalid_json(): + process = mock_subprocess(0, b"not valid json") + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises(json.JSONDecodeError): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_process_timeout(): + async def timeout_communicate(): + await asyncio.sleep(100) + return (b"", b"") + + process = AsyncMock() + process.communicate = timeout_communicate + + config = ProcessCredentialsConfig(timeout=1) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"], config=config) + with pytest.raises(SmithyIdentityError, match="timed out after 1 seconds"): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_long_term_credentials_cached(): + """Test that credentials without expiration are cached indefinitely.""" + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + resolver = ProcessCredentialsResolver(["mock-process"]) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + # Process should only be called once + assert mock_exec.call_count == 1 + # Should return the same identity instance + assert identity_one is identity_two + + +@pytest.mark.asyncio +async def test_temporary_credentials_cached_when_valid(): + """Test that temporary credentials are cached when not expired.""" + current_time = datetime.now(UTC) + timedelta(minutes=10) + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = current_time.strftime(ISO8601) + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + resolver = ProcessCredentialsResolver(["mock-process"]) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + # Process should only be called once + assert mock_exec.call_count == 1 + # Should return the same identity instance + assert identity_one is identity_two + + +@pytest.mark.asyncio +async def test_expired_credentials_refreshed(): + """Test that expired credentials are refreshed.""" + expired_time = datetime.now(UTC) - timedelta(minutes=10) + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = expired_time.strftime(ISO8601) + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + resolver = ProcessCredentialsResolver(["mock-process"]) + identity_one = await resolver.get_identity(properties={}) + identity_two = await resolver.get_identity(properties={}) + + # Process should be called twice (once for initial, once for refresh) + assert mock_exec.call_count == 2 + # Should be different instances + assert identity_one is not identity_two + # But have the same values + assert identity_one.access_key_id == identity_two.access_key_id + assert identity_one.secret_access_key == identity_two.secret_access_key + + +@pytest.mark.asyncio +async def test_command_with_multiple_args(): + """Test that commands with multiple arguments are passed correctly.""" + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + resolver = ProcessCredentialsResolver( + ["aws-credential-helper", "--profile", "test", "--format", "json"] + ) + await resolver.get_identity(properties={}) + + # Verify the command was called with all arguments + mock_exec.assert_called_once_with( + "aws-credential-helper", + "--profile", + "test", + "--format", + "json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) From dbf978f1eaecb48f654aefb97d8ca8a139726189 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 14 Mar 2026 23:39:48 -0400 Subject: [PATCH 02/15] Improve process credentials resolver command handling --- .../src/smithy_aws_core/identity/process.py | 22 ++++- .../tests/unit/identity/test_process.py | 83 +++++++++++++++++-- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index ae685a01f..2ace33e3a 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio import json +import shlex from dataclasses import dataclass from datetime import UTC, datetime @@ -30,12 +31,15 @@ class ProcessCredentialsResolver( def __init__( self, - command: list[str], + command: str | list[str], config: ProcessCredentialsConfig | None = None, ): - if not command: - raise ValueError("command must be a non-empty list") - self._command = command + normalized_command = ( + shlex.split(command) if isinstance(command, str) else command + ) + if not normalized_command: + raise ValueError("command must be a non-empty string or list") + self._command = list(normalized_command) self._config = config or ProcessCredentialsConfig() self._credentials = None @@ -56,10 +60,20 @@ async def get_identity( stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) + except OSError as e: + raise SmithyIdentityError(f"Credential process failed to start: {e}") from e + + try: stdout, stderr = await asyncio.wait_for( process.communicate(), timeout=self._config.timeout ) except TimeoutError as e: + if process.returncode is None: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() raise SmithyIdentityError( f"Credential process timed out after {self._config.timeout} seconds" ) from e diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index f9fd9e597..91eb6ef5b 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -5,13 +5,15 @@ import asyncio import json from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from smithy_aws_core.identity.components import AWSCredentialsIdentity from smithy_aws_core.identity.process import ( ProcessCredentialsConfig, ProcessCredentialsResolver, ) +from smithy_core.aio.identity import ChainedIdentityResolver from smithy_core.exceptions import SmithyIdentityError ISO8601 = "%Y-%m-%dT%H:%M:%SZ" @@ -35,15 +37,20 @@ def test_config_custom_values(): def test_resolver_empty_command(): - with pytest.raises(ValueError, match="command must be a non-empty list"): + with pytest.raises(ValueError, match="command must be a non-empty string or list"): ProcessCredentialsResolver([]) def test_resolver_none_command(): - with pytest.raises(ValueError, match="command must be a non-empty list"): + with pytest.raises(ValueError, match="command must be a non-empty string or list"): ProcessCredentialsResolver(None) # type: ignore[arg-type] +def test_resolver_empty_command_string(): + with pytest.raises(ValueError, match="command must be a non-empty string or list"): + ProcessCredentialsResolver("") + + def mock_subprocess(returncode: int, stdout: bytes, stderr: bytes = b""): """Helper to mock asyncio.create_subprocess_exec""" process = AsyncMock() @@ -206,12 +213,11 @@ async def test_invalid_json(): @pytest.mark.asyncio async def test_process_timeout(): - async def timeout_communicate(): - await asyncio.sleep(100) - return (b"", b"") - process = AsyncMock() - process.communicate = timeout_communicate + process.returncode = None + process.communicate = AsyncMock(side_effect=TimeoutError) + process.kill = Mock() + process.wait = AsyncMock() config = ProcessCredentialsConfig(timeout=1) @@ -220,6 +226,45 @@ async def timeout_communicate(): with pytest.raises(SmithyIdentityError, match="timed out after 1 seconds"): await resolver.get_identity(properties={}) + process.kill.assert_called_once_with() + process.wait.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_process_startup_failure_raises_smithy_identity_error(): + with patch( + "asyncio.create_subprocess_exec", + side_effect=FileNotFoundError("No such file or directory"), + ): + resolver = ProcessCredentialsResolver(["missing-process"]) + with pytest.raises(SmithyIdentityError, match="failed to start"): + await resolver.get_identity(properties={}) + + +@pytest.mark.asyncio +async def test_process_startup_failure_allows_chained_fallback(): + class SuccessfulResolver: + async def get_identity(self, *, properties: dict[str, str]): + return AWSCredentialsIdentity( + access_key_id="fallback-akid", + secret_access_key="fallback-secret", + ) + + with patch( + "asyncio.create_subprocess_exec", + side_effect=FileNotFoundError("No such file or directory"), + ): + resolver = ChainedIdentityResolver( + [ + ProcessCredentialsResolver(["missing-process"]), + SuccessfulResolver(), + ] + ) + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == "fallback-akid" + assert identity.secret_access_key == "fallback-secret" + @pytest.mark.asyncio async def test_long_term_credentials_cached(): @@ -305,3 +350,25 @@ async def test_command_with_multiple_args(): stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) + + +@pytest.mark.asyncio +async def test_string_command_with_multiple_args(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + resolver = ProcessCredentialsResolver( + 'aws-credential-helper --profile "test profile" --format json' + ) + await resolver.get_identity(properties={}) + + mock_exec.assert_called_once_with( + "aws-credential-helper", + "--profile", + "test profile", + "--format", + "json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) From e3f0d11953c431959a2b98e3a73f52d47e20a503 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 16 Mar 2026 10:28:26 -0400 Subject: [PATCH 03/15] fix type checking errors --- .../smithy-aws-core/tests/unit/identity/test_process.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 91eb6ef5b..1d4c8a1ea 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -8,7 +8,10 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from smithy_aws_core.identity.components import AWSCredentialsIdentity +from smithy_aws_core.identity.components import ( + AWSCredentialsIdentity, + AWSIdentityProperties, +) from smithy_aws_core.identity.process import ( ProcessCredentialsConfig, ProcessCredentialsResolver, @@ -244,7 +247,9 @@ async def test_process_startup_failure_raises_smithy_identity_error(): @pytest.mark.asyncio async def test_process_startup_failure_allows_chained_fallback(): class SuccessfulResolver: - async def get_identity(self, *, properties: dict[str, str]): + async def get_identity( + self, *, properties: AWSIdentityProperties + ) -> AWSCredentialsIdentity: return AWSCredentialsIdentity( access_key_id="fallback-akid", secret_access_key="fallback-secret", From 2ab7cddc05b16ef7379a93f313b44a265200715c Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 16 Mar 2026 10:34:59 -0400 Subject: [PATCH 04/15] Simplify example creds names --- .../tests/unit/identity/test_process.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 1d4c8a1ea..0e78e723c 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -23,9 +23,9 @@ DEFAULT_RESPONSE_DATA = { "Version": 1, - "AccessKeyId": "akid123", - "SecretAccessKey": "s3cr3t", - "SessionToken": "session_token", + "AccessKeyId": "foo", + "SecretAccessKey": "bar", + "SessionToken": "baz", } @@ -71,9 +71,9 @@ async def test_valid_credentials_with_session_token(): resolver = ProcessCredentialsResolver(["mock-process"]) identity = await resolver.get_identity(properties={}) - assert identity.access_key_id == "akid123" - assert identity.secret_access_key == "s3cr3t" - assert identity.session_token == "session_token" + assert identity.access_key_id == "foo" + assert identity.secret_access_key == "bar" + assert identity.session_token == "baz" assert identity.expiration is None assert identity.account_id is None @@ -82,8 +82,8 @@ async def test_valid_credentials_with_session_token(): async def test_valid_credentials_without_session_token(): resp_data = { "Version": 1, - "AccessKeyId": "akid456", - "SecretAccessKey": "s3cr3t456", + "AccessKeyId": "foo", + "SecretAccessKey": "bar", } resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -92,8 +92,8 @@ async def test_valid_credentials_without_session_token(): resolver = ProcessCredentialsResolver(["mock-process"]) identity = await resolver.get_identity(properties={}) - assert identity.access_key_id == "akid456" - assert identity.secret_access_key == "s3cr3t456" + assert identity.access_key_id == "foo" + assert identity.secret_access_key == "bar" assert identity.session_token is None @@ -143,7 +143,7 @@ async def test_non_zero_exit_code(): async def test_missing_access_key_id(): resp_data = { "Version": 1, - "SecretAccessKey": "s3cr3t", + "SecretAccessKey": "bar", } resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -161,7 +161,7 @@ async def test_missing_access_key_id(): async def test_missing_secret_access_key(): resp_data = { "Version": 1, - "AccessKeyId": "akid123", + "AccessKeyId": "foo", } resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -192,8 +192,8 @@ async def test_invalid_version(): @pytest.mark.asyncio async def test_missing_version(): resp_data = { - "AccessKeyId": "akid123", - "SecretAccessKey": "s3cr3t", + "AccessKeyId": "foo", + "SecretAccessKey": "bar", } resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) From daf40d1e730aac29f385572e07de9a6d64df54b2 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 16 Mar 2026 11:15:07 -0400 Subject: [PATCH 05/15] Fix process credentials timezone handling, JSON error wrapping --- .../src/smithy_aws_core/identity/process.py | 10 +- .../tests/unit/identity/test_process.py | 134 +++++++++++------- 2 files changed, 89 insertions(+), 55 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index 2ace33e3a..54af45009 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -82,7 +82,12 @@ async def get_identity( raise SmithyIdentityError( f"Credential process failed with non-zero exit code: {stderr.decode('utf-8')}" ) - creds = json.loads(stdout.decode("utf-8")) + try: + creds = json.loads(stdout.decode("utf-8")) + except json.JSONDecodeError as e: + raise SmithyIdentityError( + f"Failed to parse credential process output: {e}" + ) from e version = creds.get("Version") if version is None or version != 1: @@ -96,7 +101,8 @@ async def get_identity( account_id = creds.get("AccountId") if isinstance(expiration, str): - expiration = datetime.fromisoformat(expiration).replace(tzinfo=UTC) + dt = datetime.fromisoformat(expiration) + expiration = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) if access_key_id is None or secret_access_key is None: raise SmithyIdentityError( diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 0e78e723c..ea000f14b 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -8,15 +8,10 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from smithy_aws_core.identity.components import ( - AWSCredentialsIdentity, - AWSIdentityProperties, -) from smithy_aws_core.identity.process import ( ProcessCredentialsConfig, ProcessCredentialsResolver, ) -from smithy_core.aio.identity import ChainedIdentityResolver from smithy_core.exceptions import SmithyIdentityError ISO8601 = "%Y-%m-%dT%H:%M:%SZ" @@ -39,19 +34,10 @@ def test_config_custom_values(): assert config.timeout == 60 -def test_resolver_empty_command(): - with pytest.raises(ValueError, match="command must be a non-empty string or list"): - ProcessCredentialsResolver([]) - - -def test_resolver_none_command(): - with pytest.raises(ValueError, match="command must be a non-empty string or list"): - ProcessCredentialsResolver(None) # type: ignore[arg-type] - - -def test_resolver_empty_command_string(): +@pytest.mark.parametrize("command", [[], "", None]) +def test_resolver_invalid_command(command: object): with pytest.raises(ValueError, match="command must be a non-empty string or list"): - ProcessCredentialsResolver("") + ProcessCredentialsResolver(command) # type: ignore[arg-type] def mock_subprocess(returncode: int, stdout: bytes, stderr: bytes = b""): @@ -97,6 +83,41 @@ async def test_valid_credentials_without_session_token(): assert identity.session_token is None +@pytest.mark.asyncio +async def test_missing_expiration(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == "foo" + assert identity.secret_access_key == "bar" + assert identity.session_token == "baz" + assert identity.expiration is None + + +@pytest.mark.asyncio +async def test_missing_expiration_and_session_token(): + resp_data = { + "Version": 1, + "AccessKeyId": "foo", + "SecretAccessKey": "bar", + } + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.access_key_id == "foo" + assert identity.secret_access_key == "bar" + assert identity.session_token is None + assert identity.expiration is None + + @pytest.mark.asyncio async def test_credentials_with_expiration(): current_time = datetime.now(UTC) + timedelta(minutes=10) @@ -114,6 +135,25 @@ async def test_credentials_with_expiration(): assert identity.expiration.tzinfo == UTC +@pytest.mark.asyncio +async def test_credentials_with_non_utc_expiration(): + """Test that non-UTC expiration timestamps are correctly converted to UTC.""" + # 2026-03-16T10:00:00+05:00 should become 2026-03-16T05:00:00 UTC + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = "2026-03-16T10:00:00+05:00" + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + identity = await resolver.get_identity(properties={}) + + assert identity.expiration is not None + assert identity.expiration.tzinfo == UTC + assert identity.expiration == datetime(2026, 3, 16, 5, 0, 0, tzinfo=UTC) + + @pytest.mark.asyncio async def test_credentials_with_account_id(): resp_data = dict(DEFAULT_RESPONSE_DATA) @@ -210,7 +250,7 @@ async def test_invalid_json(): with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) - with pytest.raises(json.JSONDecodeError): + with pytest.raises(SmithyIdentityError, match="Failed to parse"): await resolver.get_identity(properties={}) @@ -244,33 +284,6 @@ async def test_process_startup_failure_raises_smithy_identity_error(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio -async def test_process_startup_failure_allows_chained_fallback(): - class SuccessfulResolver: - async def get_identity( - self, *, properties: AWSIdentityProperties - ) -> AWSCredentialsIdentity: - return AWSCredentialsIdentity( - access_key_id="fallback-akid", - secret_access_key="fallback-secret", - ) - - with patch( - "asyncio.create_subprocess_exec", - side_effect=FileNotFoundError("No such file or directory"), - ): - resolver = ChainedIdentityResolver( - [ - ProcessCredentialsResolver(["missing-process"]), - SuccessfulResolver(), - ] - ) - identity = await resolver.get_identity(properties={}) - - assert identity.access_key_id == "fallback-akid" - assert identity.secret_access_key == "fallback-secret" - - @pytest.mark.asyncio async def test_long_term_credentials_cached(): """Test that credentials without expiration are cached indefinitely.""" @@ -313,13 +326,25 @@ async def test_temporary_credentials_cached_when_valid(): async def test_expired_credentials_refreshed(): """Test that expired credentials are refreshed.""" expired_time = datetime.now(UTC) - timedelta(minutes=10) - resp_data = dict(DEFAULT_RESPONSE_DATA) - resp_data["Expiration"] = expired_time.strftime(ISO8601) + initial_data = dict(DEFAULT_RESPONSE_DATA) + initial_data["Expiration"] = expired_time.strftime(ISO8601) - resp_body = json.dumps(resp_data) - process = mock_subprocess(0, resp_body.encode("utf-8")) + refreshed_time = datetime.now(UTC) + timedelta(minutes=10) + refreshed_data = { + "Version": 1, + "AccessKeyId": "foo-refreshed", + "SecretAccessKey": "bar-refreshed", + "SessionToken": "baz-refreshed", + "Expiration": refreshed_time.strftime(ISO8601), + } - with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + first_process = mock_subprocess(0, json.dumps(initial_data).encode("utf-8")) + second_process = mock_subprocess(0, json.dumps(refreshed_data).encode("utf-8")) + + with patch( + "asyncio.create_subprocess_exec", + side_effect=[first_process, second_process], + ) as mock_exec: resolver = ProcessCredentialsResolver(["mock-process"]) identity_one = await resolver.get_identity(properties={}) identity_two = await resolver.get_identity(properties={}) @@ -328,9 +353,12 @@ async def test_expired_credentials_refreshed(): assert mock_exec.call_count == 2 # Should be different instances assert identity_one is not identity_two - # But have the same values - assert identity_one.access_key_id == identity_two.access_key_id - assert identity_one.secret_access_key == identity_two.secret_access_key + assert identity_one.access_key_id == "foo" + assert identity_one.secret_access_key == "bar" + assert identity_one.session_token == "baz" + assert identity_two.access_key_id == "foo-refreshed" + assert identity_two.secret_access_key == "bar-refreshed" + assert identity_two.session_token == "baz-refreshed" @pytest.mark.asyncio From cb4f02b8a43f0f0ac699c896d94ba190cc164c06 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 16 Mar 2026 12:44:24 -0400 Subject: [PATCH 06/15] Only allow commands as a list of strings --- .../src/smithy_aws_core/identity/process.py | 12 +++------ .../tests/unit/identity/test_process.py | 26 ++----------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index 54af45009..de22afe55 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio import json -import shlex from dataclasses import dataclass from datetime import UTC, datetime @@ -31,15 +30,12 @@ class ProcessCredentialsResolver( def __init__( self, - command: str | list[str], + command: list[str], config: ProcessCredentialsConfig | None = None, ): - normalized_command = ( - shlex.split(command) if isinstance(command, str) else command - ) - if not normalized_command: - raise ValueError("command must be a non-empty string or list") - self._command = list(normalized_command) + if not command: + raise ValueError("command must be a non-empty list") + self._command = list(command) self._config = config or ProcessCredentialsConfig() self._credentials = None diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index ea000f14b..f2bc9ec84 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -34,9 +34,9 @@ def test_config_custom_values(): assert config.timeout == 60 -@pytest.mark.parametrize("command", [[], "", None]) +@pytest.mark.parametrize("command", [[], None]) def test_resolver_invalid_command(command: object): - with pytest.raises(ValueError, match="command must be a non-empty string or list"): + with pytest.raises((ValueError, TypeError)): ProcessCredentialsResolver(command) # type: ignore[arg-type] @@ -383,25 +383,3 @@ async def test_command_with_multiple_args(): stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - - -@pytest.mark.asyncio -async def test_string_command_with_multiple_args(): - resp_body = json.dumps(DEFAULT_RESPONSE_DATA) - process = mock_subprocess(0, resp_body.encode("utf-8")) - - with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: - resolver = ProcessCredentialsResolver( - 'aws-credential-helper --profile "test profile" --format json' - ) - await resolver.get_identity(properties={}) - - mock_exec.assert_called_once_with( - "aws-credential-helper", - "--profile", - "test profile", - "--format", - "json", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) From 6ee36dd0caf570109a3417319c894287b6c63ec9 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Wed, 15 Apr 2026 00:30:08 -0400 Subject: [PATCH 07/15] Update non-zero ecxeption message based on feedback --- .../smithy-aws-core/src/smithy_aws_core/identity/process.py | 3 ++- packages/smithy-aws-core/tests/unit/identity/test_process.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index de22afe55..aa1d21d88 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -76,7 +76,8 @@ async def get_identity( if process.returncode != 0: raise SmithyIdentityError( - f"Credential process failed with non-zero exit code: {stderr.decode('utf-8')}" + f"Credential process failed with exit code {process.returncode}: " + f"{stderr.decode('utf-8', errors='replace')}" ) try: creds = json.loads(stdout.decode("utf-8")) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index f2bc9ec84..23d98a365 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -175,7 +175,10 @@ async def test_non_zero_exit_code(): with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) - with pytest.raises(SmithyIdentityError, match="non-zero exit code"): + with pytest.raises( + SmithyIdentityError, + match="exit code 1: Process error message", + ): await resolver.get_identity(properties={}) From 713c6f1a752e74dcbc551d09a207a94befbefa42 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 1 Aug 2026 22:42:40 -0400 Subject: [PATCH 08/15] Integrate with new credential chain --- ...ture-9e2d74d0c5724eacbee1b1af6260ab54.json | 4 + packages/smithy-aws-core/pyproject.toml | 1 + .../src/smithy_aws_core/identity/__init__.py | 6 +- .../identity/chain/providers/process.py | 42 +++++++ .../src/smithy_aws_core/identity/process.py | 24 ++-- .../identity/chain/providers/test_process.py | 107 ++++++++++++++++++ .../tests/unit/identity/test_process.py | 23 +++- 7 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-9e2d74d0c5724eacbee1b1af6260ab54.json create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py create mode 100644 packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py diff --git a/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-9e2d74d0c5724eacbee1b1af6260ab54.json b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-9e2d74d0c5724eacbee1b1af6260ab54.json new file mode 100644 index 000000000..e5bcf9b7a --- /dev/null +++ b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-9e2d74d0c5724eacbee1b1af6260ab54.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added process credentials support to the default AWS identity chain through the active profile's `credential_process` setting." +} diff --git a/packages/smithy-aws-core/pyproject.toml b/packages/smithy-aws-core/pyproject.toml index cb2f732cf..8439bc665 100644 --- a/packages/smithy-aws-core/pyproject.toml +++ b/packages/smithy-aws-core/pyproject.toml @@ -42,6 +42,7 @@ Environment = "smithy_aws_core.identity.chain.providers.environment:EnvironmentC SharedConfig = "smithy_aws_core.identity.chain.providers.shared_config:SharedConfigProvider" ProfileSessionKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileSessionCredentialsProvider" ProfileStaticKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileStaticCredentialsProvider" +ProfileCredentialProcess = "smithy_aws_core.identity.chain.providers.process:ProfileProcessCredentialsProvider" [build-system] requires = ["hatchling"] diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index f48aee065..f268c6cd2 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -4,6 +4,7 @@ from .chain import IdentityChain, IdentityChainError, UnclaimedSource from .chain.providers.environment import EnvironmentCredentialsProvider +from .chain.providers.process import ProfileProcessCredentialsProvider from .chain.providers.profile import ( ProfileSessionCredentialsProvider, ProfileStaticCredentialsProvider, @@ -31,10 +32,11 @@ "IMDSCredentialsResolver", "IdentityChain", "IdentityChainError", - "ProfileSessionCredentialsProvider", - "ProfileStaticCredentialsProvider", "ProcessCredentialsConfig", "ProcessCredentialsResolver", + "ProfileProcessCredentialsProvider", + "ProfileSessionCredentialsProvider", + "ProfileStaticCredentialsProvider", "SharedConfigProvider", "StaticCredentialsResolver", "UnclaimedSource", diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py new file mode 100644 index 000000000..331947b81 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py @@ -0,0 +1,42 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import shlex + +from smithy_core.interfaces.identity import Identity + +from ...components import AWSCredentialsIdentity +from ...process import ProcessCredentialsResolver +from ..ordering import Standard, StandardProvider +from ..provider import ChainSetup + +_CREDENTIAL_PROCESS = "credential_process" + + +class ProfileProcessCredentialsProvider: + """Adds a process credential resolver configured by the active profile.""" + + @property + def name(self) -> str: + """Return the canonical provider name.""" + return StandardProvider.PROFILE_CREDENTIAL_PROCESS.canonical_name + + @property + def ordering(self) -> Standard: + """Return the provider's standard chain position.""" + return Standard(slot=StandardProvider.PROFILE_CREDENTIAL_PROCESS) + + async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: + """Add a resolver when the active profile configures a credential process.""" + if identity_type is not AWSCredentialsIdentity: + return + + config_file = setup.config_file + profile_name = setup.profile_name + if config_file is None or profile_name is None: + return + + command = config_file.get(profile_name, _CREDENTIAL_PROCESS) + if not command: + return + + setup.add_terminal_resolver(ProcessCredentialsResolver(shlex.split(command))) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index aa1d21d88..3129ad486 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -4,18 +4,22 @@ import json from dataclasses import dataclass from datetime import UTC, datetime +from typing import TypeGuard, cast from smithy_core.aio.interfaces.identity import IdentityResolver from smithy_core.exceptions import SmithyIdentityError -from smithy_aws_core.identity.components import ( - AWSCredentialsIdentity, - AWSIdentityProperties, -) +from .components import AWSCredentialsIdentity, AWSIdentityProperties _DEFAULT_TIMEOUT = 30 +def _is_command_list(command: object) -> TypeGuard[list[str]]: + if not isinstance(command, list) or not command: + return False + return all(isinstance(argument, str) for argument in cast(list[object], command)) + + @dataclass class ProcessCredentialsConfig: """Configuration for process credential retrieval operations.""" @@ -32,12 +36,12 @@ def __init__( self, command: list[str], config: ProcessCredentialsConfig | None = None, - ): - if not command: - raise ValueError("command must be a non-empty list") + ) -> None: + if not _is_command_list(command): + raise ValueError("command must be a non-empty list of strings") self._command = list(command) self._config = config or ProcessCredentialsConfig() - self._credentials = None + self._credentials: AWSCredentialsIdentity | None = None async def get_identity( self, *, properties: AWSIdentityProperties @@ -114,3 +118,7 @@ async def get_identity( account_id=account_id, ) return self._credentials + + async def invalidate(self) -> None: + """Discard cached credentials so the next resolution reruns the process.""" + self._credentials = None diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py new file mode 100644 index 000000000..b91d91c43 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py @@ -0,0 +1,107 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import asyncio +import json +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, patch + +from smithy_aws_core.config.merged_config import MergedConfig +from smithy_aws_core.identity.chain.provider import ChainSetup +from smithy_aws_core.identity.chain.providers.process import ( + ProfileProcessCredentialsProvider, +) +from smithy_aws_core.identity.process import ProcessCredentialsResolver + +from .conftest import OtherIdentity + + +async def test_ignores_non_aws_identity_type( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + provider = ProfileProcessCredentialsProvider() + + setup = await setup_provider( + provider, + identity_type=OtherIdentity, + config_file=merged_config( + {"default": {"credential_process": "credential-helper"}} + ), + profile_name="default", + ) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_requires_active_profile( + setup_provider: Callable[..., Awaitable[ChainSetup]], +) -> None: + setup = await setup_provider(ProfileProcessCredentialsProvider()) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_missing_process_does_not_register( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + setup = await setup_provider( + ProfileProcessCredentialsProvider(), + config_file=merged_config({"default": {}}), + profile_name="default", + ) + + assert setup.resolvers == () + assert not setup.terminal + + +async def test_registers_terminal_resolver( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + setup = await setup_provider( + ProfileProcessCredentialsProvider(), + config_file=merged_config( + { + "default": { + "credential_process": ( + 'credential-helper --profile "test profile" --format json' + ) + } + } + ), + profile_name="default", + ) + + assert setup.terminal + assert len(setup.resolvers) == 1 + assert setup.resolvers[0].provider_name == "ProfileCredentialProcess" + assert isinstance(setup.resolvers[0].resolver, ProcessCredentialsResolver) + + process = AsyncMock() + process.returncode = 0 + process.communicate.return_value = ( + json.dumps( + { + "Version": 1, + "AccessKeyId": "akid", + "SecretAccessKey": "secret", + } + ).encode(), + b"", + ) + with patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec: + identity = await setup.resolvers[0].get_identity(properties={}) + + assert identity.access_key_id == "akid" + mock_exec.assert_called_once_with( + "credential-helper", + "--profile", + "test profile", + "--format", + "json", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 23d98a365..f01db2247 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -34,9 +34,9 @@ def test_config_custom_values(): assert config.timeout == 60 -@pytest.mark.parametrize("command", [[], None]) +@pytest.mark.parametrize("command", [[], None, "mock-process", ["mock-process", 1]]) def test_resolver_invalid_command(command: object): - with pytest.raises((ValueError, TypeError)): + with pytest.raises(ValueError, match="command must be a non-empty list"): ProcessCredentialsResolver(command) # type: ignore[arg-type] @@ -364,6 +364,25 @@ async def test_expired_credentials_refreshed(): assert identity_two.session_token == "baz-refreshed" +@pytest.mark.asyncio +async def test_invalidate_clears_cached_credentials(): + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + first_process = mock_subprocess(0, resp_body.encode("utf-8")) + second_process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch( + "asyncio.create_subprocess_exec", + side_effect=[first_process, second_process], + ) as mock_exec: + resolver = ProcessCredentialsResolver(["mock-process"]) + identity_one = await resolver.get_identity(properties={}) + await resolver.invalidate() + identity_two = await resolver.get_identity(properties={}) + + assert mock_exec.call_count == 2 + assert identity_one is not identity_two + + @pytest.mark.asyncio async def test_command_with_multiple_args(): """Test that commands with multiple arguments are passed correctly.""" From 4c9548211b4bc31f294506a51ea6d13fb6dd3075 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 1 Aug 2026 22:59:03 -0400 Subject: [PATCH 09/15] Support Windows command parsing for process credentials --- .../identity/chain/providers/process.py | 68 ++++++++++++++++- .../identity/chain/providers/test_process.py | 75 +++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py index 331947b81..5aabfadfc 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py @@ -1,6 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 import shlex +import sys from smithy_core.interfaces.identity import Identity @@ -12,6 +13,69 @@ _CREDENTIAL_PROCESS = "credential_process" +def _split_process_command( + command: str, + *, + platform: str | None = None, +) -> list[str]: + """Split a process command according to the host platform's quoting rules.""" + if platform is None: + platform = sys.platform + if platform == "win32": + return _split_windows_command(command) + return shlex.split(command) + + +def _split_windows_command(command: str) -> list[str]: + """Split a command using the Microsoft C runtime argument parsing rules.""" + arguments: list[str] = [] + argument: list[str] = [] + argument_started = False + in_quotes = False + backslashes = 0 + + for character in command: + if character == "\\": + backslashes += 1 + argument_started = True + continue + + if character == '"': + literal_backslashes, escaped_quote = divmod(backslashes, 2) + argument.extend("\\" * literal_backslashes) + backslashes = 0 + argument_started = True + if escaped_quote: + argument.append('"') + else: + in_quotes = not in_quotes + continue + + if backslashes: + argument.extend("\\" * backslashes) + backslashes = 0 + + if character in (" ", "\t") and not in_quotes: + if argument_started: + arguments.append("".join(argument)) + argument = [] + argument_started = False + continue + + argument.append(character) + argument_started = True + + if in_quotes: + raise ValueError(f"No closing quotation in string: {command}") + + if backslashes: + argument.extend("\\" * backslashes) + if argument_started: + arguments.append("".join(argument)) + + return arguments + + class ProfileProcessCredentialsProvider: """Adds a process credential resolver configured by the active profile.""" @@ -39,4 +103,6 @@ async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: if not command: return - setup.add_terminal_resolver(ProcessCredentialsResolver(shlex.split(command))) + setup.add_terminal_resolver( + ProcessCredentialsResolver(_split_process_command(command)) + ) diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py index b91d91c43..99c335041 100644 --- a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py @@ -1,20 +1,95 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false import asyncio import json +import subprocess from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch +import pytest from smithy_aws_core.config.merged_config import MergedConfig from smithy_aws_core.identity.chain.provider import ChainSetup from smithy_aws_core.identity.chain.providers.process import ( ProfileProcessCredentialsProvider, + _split_process_command, ) from smithy_aws_core.identity.process import ProcessCredentialsResolver from .conftest import OtherIdentity +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("", []), + ("spam eggs", ["spam", "eggs"]), + ("spam\teggs", ["spam", "eggs"]), + ("spam\neggs", ["spam\neggs"]), + ('""', [""]), + ('" "', [" "]), + ('"\t"', ["\t"]), + (r"spam \\", ["spam", r"\\"]), + (r"\\", [r"\\"]), + (r"\\ ", [r"\\"]), + (r"\\ ", [r"\\"]), + (r"\"", ['"']), + ( + r"C:\Tools\awscreds.exe --profile dev", + [r"C:\Tools\awscreds.exe", "--profile", "dev"], + ), + ( + r'"C:\Program Files\awscreds.exe" --profile "test profile"', + [r"C:\Program Files\awscreds.exe", "--profile", "test profile"], + ), + (r'"abc" d e', ["abc", "d", "e"]), + (r'a\\b d"e f"g h', [r"a\\b", "de fg", "h"]), + (r"a\\\"b c d", ['a\\"b', "c", "d"]), + (r'a\\\\"b c" d e', [r"a\\b c", "d", "e"]), + ], +) +def test_split_process_command_windows( + command: str, + expected: list[str], +) -> None: + assert _split_process_command(command, platform="win32") == expected + + +@pytest.mark.parametrize( + "arguments", + [ + [r"C:\Tools\awscreds.exe", "--profile", "dev"], + [r"C:\Program Files\awscreds.exe", "--profile", "test profile"], + ["credential-helper", "", "embedded space"], + ["credential-helper", 'embedded"quote', "trailing\\"], + ["credential-helper", r"multiple\\backslashes", r'backslash\\"quote'], + ], +) +def test_split_process_command_windows_round_trips_python_arguments( + arguments: list[str], +) -> None: + command = subprocess.list2cmdline(arguments) + + assert _split_process_command(command, platform="win32") == arguments + + +@pytest.mark.parametrize("platform", ["darwin", "linux"]) +def test_split_process_command_posix(platform: str) -> None: + command = r'/opt/My\ Tools/awscreds --profile "test profile"' + + assert _split_process_command(command, platform=platform) == [ + "/opt/My Tools/awscreds", + "--profile", + "test profile", + ] + + +@pytest.mark.parametrize("platform", ["darwin", "linux", "win32"]) +def test_split_process_command_rejects_unclosed_quote(platform: str) -> None: + with pytest.raises(ValueError, match="No closing quotation"): + _split_process_command('"credential-helper', platform=platform) + + async def test_ignores_non_aws_identity_type( setup_provider: Callable[..., Awaitable[ChainSetup]], merged_config: Callable[..., MergedConfig], From 8011fca43d365daac7a80bbf4e9a1bed50aad806 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sat, 1 Aug 2026 23:18:50 -0400 Subject: [PATCH 10/15] Simplify process credential timeout configuration Replace ProcessCredentialsConfig with a keyword-only timeout argument on ProcessCredentialsResolver. Default to no timeout to match the SEP and existing AWS SDK behavior. --- .../src/smithy_aws_core/identity/__init__.py | 3 +-- .../src/smithy_aws_core/identity/process.py | 19 +++++-------------- .../tests/unit/identity/test_process.py | 19 ++----------------- 3 files changed, 8 insertions(+), 33 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index f268c6cd2..2fe508952 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -19,7 +19,7 @@ from .container import ContainerCredentialsResolver from .environment import EnvironmentCredentialsResolver from .imds import IMDSCredentialsResolver -from .process import ProcessCredentialsConfig, ProcessCredentialsResolver +from .process import ProcessCredentialsResolver from .static import StaticCredentialsResolver __all__ = ( @@ -32,7 +32,6 @@ "IMDSCredentialsResolver", "IdentityChain", "IdentityChainError", - "ProcessCredentialsConfig", "ProcessCredentialsResolver", "ProfileProcessCredentialsProvider", "ProfileSessionCredentialsProvider", diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index 3129ad486..c9d04f156 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio import json -from dataclasses import dataclass from datetime import UTC, datetime from typing import TypeGuard, cast @@ -11,8 +10,6 @@ from .components import AWSCredentialsIdentity, AWSIdentityProperties -_DEFAULT_TIMEOUT = 30 - def _is_command_list(command: object) -> TypeGuard[list[str]]: if not isinstance(command, list) or not command: @@ -20,13 +17,6 @@ def _is_command_list(command: object) -> TypeGuard[list[str]]: return all(isinstance(argument, str) for argument in cast(list[object], command)) -@dataclass -class ProcessCredentialsConfig: - """Configuration for process credential retrieval operations.""" - - timeout: int = _DEFAULT_TIMEOUT - - class ProcessCredentialsResolver( IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] ): @@ -35,12 +25,13 @@ class ProcessCredentialsResolver( def __init__( self, command: list[str], - config: ProcessCredentialsConfig | None = None, + *, + timeout: float | None = None, ) -> None: if not _is_command_list(command): raise ValueError("command must be a non-empty list of strings") self._command = list(command) - self._config = config or ProcessCredentialsConfig() + self._timeout = timeout self._credentials: AWSCredentialsIdentity | None = None async def get_identity( @@ -65,7 +56,7 @@ async def get_identity( try: stdout, stderr = await asyncio.wait_for( - process.communicate(), timeout=self._config.timeout + process.communicate(), timeout=self._timeout ) except TimeoutError as e: if process.returncode is None: @@ -75,7 +66,7 @@ async def get_identity( pass await process.wait() raise SmithyIdentityError( - f"Credential process timed out after {self._config.timeout} seconds" + f"Credential process timed out after {self._timeout} seconds" ) from e if process.returncode != 0: diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index f01db2247..c41e7f591 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -8,10 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from smithy_aws_core.identity.process import ( - ProcessCredentialsConfig, - ProcessCredentialsResolver, -) +from smithy_aws_core.identity.process import ProcessCredentialsResolver from smithy_core.exceptions import SmithyIdentityError ISO8601 = "%Y-%m-%dT%H:%M:%SZ" @@ -24,16 +21,6 @@ } -def test_config_default_values(): - config = ProcessCredentialsConfig() - assert config.timeout == 30 - - -def test_config_custom_values(): - config = ProcessCredentialsConfig(timeout=60) - assert config.timeout == 60 - - @pytest.mark.parametrize("command", [[], None, "mock-process", ["mock-process", 1]]) def test_resolver_invalid_command(command: object): with pytest.raises(ValueError, match="command must be a non-empty list"): @@ -265,10 +252,8 @@ async def test_process_timeout(): process.kill = Mock() process.wait = AsyncMock() - config = ProcessCredentialsConfig(timeout=1) - with patch("asyncio.create_subprocess_exec", return_value=process): - resolver = ProcessCredentialsResolver(["mock-process"], config=config) + resolver = ProcessCredentialsResolver(["mock-process"], timeout=1) with pytest.raises(SmithyIdentityError, match="timed out after 1 seconds"): await resolver.get_identity(properties={}) From e189b7bee8d1f61197beb78b901078e0c80060b4 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 2 Aug 2026 00:57:15 -0400 Subject: [PATCH 11/15] Harden process credential parsing and add aws_account_id fallback Wrap non-UTF-8 stdout and malformed Expiration in SmithyIdentityError, drop the redundant version None check, and fall back to the profile's aws_account_id when the process output omits AccountId. --- .../identity/chain/providers/process.py | 8 ++++- .../src/smithy_aws_core/identity/process.py | 26 +++++++++++--- .../identity/chain/providers/test_process.py | 35 +++++++++++++++++++ .../tests/unit/identity/test_process.py | 33 +++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py index 5aabfadfc..3821ee551 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py @@ -11,6 +11,7 @@ from ..provider import ChainSetup _CREDENTIAL_PROCESS = "credential_process" +_ACCOUNT_ID = "aws_account_id" def _split_process_command( @@ -103,6 +104,11 @@ async def setup(self, identity_type: type[Identity], setup: ChainSetup) -> None: if not command: return + # The process output's AccountId takes precedence; the profile's + # aws_account_id is only used as a fallback. setup.add_terminal_resolver( - ProcessCredentialsResolver(_split_process_command(command)) + ProcessCredentialsResolver( + _split_process_command(command), + account_id=config_file.get(profile_name, _ACCOUNT_ID), + ) ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index c9d04f156..d13c5c60f 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -20,18 +20,27 @@ def _is_command_list(command: object) -> TypeGuard[list[str]]: class ProcessCredentialsResolver( IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] ): - """Resolves AWS Credentials from a process.""" + """Resolves AWS Credentials from a process. + + :param command: The process command and arguments to execute, as a + non-empty list of strings. + :param timeout: Maximum time in seconds to wait for the process to complete. + :param account_id: Fallback account ID to associate with the resolved + credentials when the process output does not include an ``AccountId``. + """ def __init__( self, command: list[str], *, timeout: float | None = None, + account_id: str | None = None, ) -> None: if not _is_command_list(command): raise ValueError("command must be a non-empty list of strings") self._command = list(command) self._timeout = timeout + self._account_id = account_id self._credentials: AWSCredentialsIdentity | None = None async def get_identity( @@ -76,13 +85,13 @@ async def get_identity( ) try: creds = json.loads(stdout.decode("utf-8")) - except json.JSONDecodeError as e: + except (UnicodeDecodeError, json.JSONDecodeError) as e: raise SmithyIdentityError( f"Failed to parse credential process output: {e}" ) from e version = creds.get("Version") - if version is None or version != 1: + if version != 1: raise SmithyIdentityError( f"Unsupported version '{version}' for credential process provider, supported versions: 1" ) @@ -90,10 +99,17 @@ async def get_identity( secret_access_key = creds.get("SecretAccessKey") session_token = creds.get("SessionToken") expiration = creds.get("Expiration") - account_id = creds.get("AccountId") + # Prefer the process output's AccountId, falling back to the profile's + # aws_account_id when the process omits it. + account_id = creds.get("AccountId") or self._account_id if isinstance(expiration, str): - dt = datetime.fromisoformat(expiration) + try: + dt = datetime.fromisoformat(expiration) + except ValueError as e: + raise SmithyIdentityError( + f"Failed to parse credential process expiration: {e}" + ) from e expiration = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) if access_key_id is None or secret_access_key is None: diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py index 99c335041..f6a2b873e 100644 --- a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py @@ -180,3 +180,38 @@ async def test_registers_terminal_resolver( stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) + + +async def test_account_id_falls_back_to_profile_config( + setup_provider: Callable[..., Awaitable[ChainSetup]], + merged_config: Callable[..., MergedConfig], +) -> None: + setup = await setup_provider( + ProfileProcessCredentialsProvider(), + config_file=merged_config( + { + "default": { + "credential_process": "credential-helper", + "aws_account_id": "123456789012", + } + } + ), + profile_name="default", + ) + + process = AsyncMock() + process.returncode = 0 + process.communicate.return_value = ( + json.dumps( + { + "Version": 1, + "AccessKeyId": "akid", + "SecretAccessKey": "secret", + } + ).encode(), + b"", + ) + with patch("asyncio.create_subprocess_exec", return_value=process): + identity = await setup.resolvers[0].get_identity(properties={}) + + assert identity.account_id == "123456789012" diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index c41e7f591..7e8189e2b 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -156,6 +156,39 @@ async def test_credentials_with_account_id(): assert identity.account_id == "123456789012" +@pytest.mark.asyncio +async def test_account_id_falls_back_to_configured_value(): + """The configured account_id is used when the process omits AccountId.""" + resp_body = json.dumps(DEFAULT_RESPONSE_DATA) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver( + ["mock-process"], account_id="123456789012" + ) + identity = await resolver.get_identity(properties={}) + + assert identity.account_id == "123456789012" + + +@pytest.mark.asyncio +async def test_process_account_id_takes_precedence_over_configured_value(): + """The process output's AccountId wins over the configured fallback.""" + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["AccountId"] = "111111111111" + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver( + ["mock-process"], account_id="222222222222" + ) + identity = await resolver.get_identity(properties={}) + + assert identity.account_id == "111111111111" + + @pytest.mark.asyncio async def test_non_zero_exit_code(): process = mock_subprocess(1, b"", b"Process error message") From 0c48d9084225aac1d68a8174bb063943b18461b7 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 2 Aug 2026 01:30:49 -0400 Subject: [PATCH 12/15] Drop unused import --- packages/smithy-aws-core/tests/unit/identity/test_process.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 7e8189e2b..1c545472c 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -1,7 +1,5 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - import asyncio import json from datetime import UTC, datetime, timedelta From c7af394c87e117c7bc4f6abe51f897f52310a261 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 2 Aug 2026 01:58:29 -0400 Subject: [PATCH 13/15] Address test related feedback --- .../src/smithy_aws_core/identity/process.py | 7 +- .../tests/unit/identity/test_process.py | 119 ++++++------------ 2 files changed, 47 insertions(+), 79 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index d13c5c60f..140a5ebe1 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -103,7 +103,12 @@ async def get_identity( # aws_account_id when the process omits it. account_id = creds.get("AccountId") or self._account_id - if isinstance(expiration, str): + if expiration is not None: + if not isinstance(expiration, str): + raise SmithyIdentityError( + "Expiration must be an ISO8601 string, received: " + f"{type(expiration).__name__}" + ) try: dt = datetime.fromisoformat(expiration) except ValueError as e: diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 1c545472c..69dd1953f 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -33,7 +33,6 @@ def mock_subprocess(returncode: int, stdout: bytes, stderr: bytes = b""): return process -@pytest.mark.asyncio async def test_valid_credentials_with_session_token(): resp_body = json.dumps(DEFAULT_RESPONSE_DATA) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -49,7 +48,6 @@ async def test_valid_credentials_with_session_token(): assert identity.account_id is None -@pytest.mark.asyncio async def test_valid_credentials_without_session_token(): resp_data = { "Version": 1, @@ -68,28 +66,28 @@ async def test_valid_credentials_without_session_token(): assert identity.session_token is None -@pytest.mark.asyncio -async def test_missing_expiration(): - resp_body = json.dumps(DEFAULT_RESPONSE_DATA) +async def test_credentials_with_expiration(): + current_time = datetime.now(UTC) + timedelta(minutes=10) + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = current_time.strftime(ISO8601) + + resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) identity = await resolver.get_identity(properties={}) - assert identity.access_key_id == "foo" - assert identity.secret_access_key == "bar" - assert identity.session_token == "baz" - assert identity.expiration is None + assert identity.expiration is not None + assert identity.expiration.tzinfo == UTC -@pytest.mark.asyncio -async def test_missing_expiration_and_session_token(): - resp_data = { - "Version": 1, - "AccessKeyId": "foo", - "SecretAccessKey": "bar", - } +async def test_credentials_with_non_utc_expiration(): + """Test that non-UTC expiration timestamps are correctly converted to UTC.""" + # 2026-03-16T10:00:00+05:00 should become 2026-03-16T05:00:00 UTC + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["Expiration"] = "2026-03-16T10:00:00+05:00" + resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -97,49 +95,41 @@ async def test_missing_expiration_and_session_token(): resolver = ProcessCredentialsResolver(["mock-process"]) identity = await resolver.get_identity(properties={}) - assert identity.access_key_id == "foo" - assert identity.secret_access_key == "bar" - assert identity.session_token is None - assert identity.expiration is None + assert identity.expiration is not None + assert identity.expiration.tzinfo == UTC + assert identity.expiration == datetime(2026, 3, 16, 5, 0, 0, tzinfo=UTC) -@pytest.mark.asyncio -async def test_credentials_with_expiration(): - current_time = datetime.now(UTC) + timedelta(minutes=10) +async def test_invalid_expiration_string(): resp_data = dict(DEFAULT_RESPONSE_DATA) - resp_data["Expiration"] = current_time.strftime(ISO8601) + resp_data["Expiration"] = "not-a-timestamp" resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) - identity = await resolver.get_identity(properties={}) - - assert identity.expiration is not None - assert identity.expiration.tzinfo == UTC + with pytest.raises( + SmithyIdentityError, match="Failed to parse credential process expiration" + ): + await resolver.get_identity(properties={}) -@pytest.mark.asyncio -async def test_credentials_with_non_utc_expiration(): - """Test that non-UTC expiration timestamps are correctly converted to UTC.""" - # 2026-03-16T10:00:00+05:00 should become 2026-03-16T05:00:00 UTC +async def test_non_string_expiration(): resp_data = dict(DEFAULT_RESPONSE_DATA) - resp_data["Expiration"] = "2026-03-16T10:00:00+05:00" + resp_data["Expiration"] = 12345 resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) - identity = await resolver.get_identity(properties={}) - - assert identity.expiration is not None - assert identity.expiration.tzinfo == UTC - assert identity.expiration == datetime(2026, 3, 16, 5, 0, 0, tzinfo=UTC) + with pytest.raises( + SmithyIdentityError, match="Expiration must be an ISO8601 string" + ): + await resolver.get_identity(properties={}) -@pytest.mark.asyncio async def test_credentials_with_account_id(): resp_data = dict(DEFAULT_RESPONSE_DATA) resp_data["AccountId"] = "123456789012" @@ -154,7 +144,6 @@ async def test_credentials_with_account_id(): assert identity.account_id == "123456789012" -@pytest.mark.asyncio async def test_account_id_falls_back_to_configured_value(): """The configured account_id is used when the process omits AccountId.""" resp_body = json.dumps(DEFAULT_RESPONSE_DATA) @@ -169,7 +158,6 @@ async def test_account_id_falls_back_to_configured_value(): assert identity.account_id == "123456789012" -@pytest.mark.asyncio async def test_process_account_id_takes_precedence_over_configured_value(): """The process output's AccountId wins over the configured fallback.""" resp_data = dict(DEFAULT_RESPONSE_DATA) @@ -187,7 +175,6 @@ async def test_process_account_id_takes_precedence_over_configured_value(): assert identity.account_id == "111111111111" -@pytest.mark.asyncio async def test_non_zero_exit_code(): process = mock_subprocess(1, b"", b"Process error message") @@ -200,12 +187,14 @@ async def test_non_zero_exit_code(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio -async def test_missing_access_key_id(): - resp_data = { - "Version": 1, - "SecretAccessKey": "bar", - } +@pytest.mark.parametrize( + "resp_data", + [ + {"Version": 1, "SecretAccessKey": "bar"}, + {"Version": 1, "AccessKeyId": "foo"}, + ], +) +async def test_missing_required_credentials(resp_data: dict[str, object]): resp_body = json.dumps(resp_data) process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -218,25 +207,6 @@ async def test_missing_access_key_id(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio -async def test_missing_secret_access_key(): - resp_data = { - "Version": 1, - "AccessKeyId": "foo", - } - resp_body = json.dumps(resp_data) - process = mock_subprocess(0, resp_body.encode("utf-8")) - - with patch("asyncio.create_subprocess_exec", return_value=process): - resolver = ProcessCredentialsResolver(["mock-process"]) - with pytest.raises( - SmithyIdentityError, - match="AccessKeyId and SecretAccessKey are required", - ): - await resolver.get_identity(properties={}) - - -@pytest.mark.asyncio async def test_invalid_version(): resp_data = dict(DEFAULT_RESPONSE_DATA) resp_data["Version"] = 2 @@ -250,7 +220,6 @@ async def test_invalid_version(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio async def test_missing_version(): resp_data = { "AccessKeyId": "foo", @@ -265,7 +234,6 @@ async def test_missing_version(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio async def test_invalid_json(): process = mock_subprocess(0, b"not valid json") @@ -275,15 +243,16 @@ async def test_invalid_json(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio async def test_process_timeout(): process = AsyncMock() process.returncode = None - process.communicate = AsyncMock(side_effect=TimeoutError) process.kill = Mock() process.wait = AsyncMock() - with patch("asyncio.create_subprocess_exec", return_value=process): + with ( + patch("asyncio.create_subprocess_exec", return_value=process), + patch("asyncio.wait_for", side_effect=TimeoutError), + ): resolver = ProcessCredentialsResolver(["mock-process"], timeout=1) with pytest.raises(SmithyIdentityError, match="timed out after 1 seconds"): await resolver.get_identity(properties={}) @@ -292,7 +261,6 @@ async def test_process_timeout(): process.wait.assert_awaited_once_with() -@pytest.mark.asyncio async def test_process_startup_failure_raises_smithy_identity_error(): with patch( "asyncio.create_subprocess_exec", @@ -303,7 +271,6 @@ async def test_process_startup_failure_raises_smithy_identity_error(): await resolver.get_identity(properties={}) -@pytest.mark.asyncio async def test_long_term_credentials_cached(): """Test that credentials without expiration are cached indefinitely.""" resp_body = json.dumps(DEFAULT_RESPONSE_DATA) @@ -320,7 +287,6 @@ async def test_long_term_credentials_cached(): assert identity_one is identity_two -@pytest.mark.asyncio async def test_temporary_credentials_cached_when_valid(): """Test that temporary credentials are cached when not expired.""" current_time = datetime.now(UTC) + timedelta(minutes=10) @@ -341,7 +307,6 @@ async def test_temporary_credentials_cached_when_valid(): assert identity_one is identity_two -@pytest.mark.asyncio async def test_expired_credentials_refreshed(): """Test that expired credentials are refreshed.""" expired_time = datetime.now(UTC) - timedelta(minutes=10) @@ -380,7 +345,6 @@ async def test_expired_credentials_refreshed(): assert identity_two.session_token == "baz-refreshed" -@pytest.mark.asyncio async def test_invalidate_clears_cached_credentials(): resp_body = json.dumps(DEFAULT_RESPONSE_DATA) first_process = mock_subprocess(0, resp_body.encode("utf-8")) @@ -399,7 +363,6 @@ async def test_invalidate_clears_cached_credentials(): assert identity_one is not identity_two -@pytest.mark.asyncio async def test_command_with_multiple_args(): """Test that commands with multiple arguments are passed correctly.""" resp_body = json.dumps(DEFAULT_RESPONSE_DATA) From 8dbac3eaf31df6747a3d75f1d4be96e8a107797b Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 2 Aug 2026 13:00:31 -0400 Subject: [PATCH 14/15] Minor improvements after self review --- .../identity/chain/providers/process.py | 11 ++++++- .../src/smithy_aws_core/identity/process.py | 29 ++++++++++------- .../tests/unit/identity/test_process.py | 32 ++++++++++++++++--- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py index 3821ee551..0403e0b22 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py @@ -28,7 +28,11 @@ def _split_process_command( def _split_windows_command(command: str) -> list[str]: - """Split a command using the Microsoft C runtime argument parsing rules.""" + """Split a command using botocore's strict form of the Microsoft C runtime rules. + + The underlying runtime rules are documented at: + https://learn.microsoft.com/en-us/cpp/cpp/main-function-command-line-args#parsing-c-command-line-arguments + """ arguments: list[str] = [] argument: list[str] = [] argument_started = False @@ -37,11 +41,13 @@ def _split_windows_command(command: str) -> list[str]: for character in command: if character == "\\": + # Delay emitting backslashes until we know whether a quote follows. backslashes += 1 argument_started = True continue if character == '"': + # Pairs become literal backslashes; an odd remainder escapes the quote. literal_backslashes, escaped_quote = divmod(backslashes, 2) argument.extend("\\" * literal_backslashes) backslashes = 0 @@ -53,10 +59,13 @@ def _split_windows_command(command: str) -> list[str]: continue if backslashes: + # Without a following quote, backslashes are literal. argument.extend("\\" * backslashes) backslashes = 0 + # Only spaces and tabs outside quotes delimit Windows arguments. if character in (" ", "\t") and not in_quotes: + # This preserves empty quoted arguments while ignoring extra whitespace. if argument_started: arguments.append("".join(argument)) argument = [] diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index 140a5ebe1..0a2ce3d8d 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -73,7 +73,7 @@ async def get_identity( process.kill() except ProcessLookupError: pass - await process.wait() + await process.wait() raise SmithyIdentityError( f"Credential process timed out after {self._timeout} seconds" ) from e @@ -83,12 +83,21 @@ async def get_identity( f"Credential process failed with exit code {process.returncode}: " f"{stderr.decode('utf-8', errors='replace')}" ) + # These exceptions retain the full process output, which may contain + # credentials. Suppress chaining to avoid exposing it in tracebacks. try: - creds = json.loads(stdout.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as e: + decoded = stdout.decode("utf-8") + creds = json.loads(decoded) + except UnicodeDecodeError as e: raise SmithyIdentityError( - f"Failed to parse credential process output: {e}" - ) from e + "Credential process output is not valid UTF-8 " + f"at byte {e.start}: {e.reason}" + ) from None + except json.JSONDecodeError as e: + raise SmithyIdentityError( + "Credential process output is not valid JSON " + f"at line {e.lineno}, column {e.colno}: {e.msg}" + ) from None version = creds.get("Version") if version != 1: @@ -104,16 +113,12 @@ async def get_identity( account_id = creds.get("AccountId") or self._account_id if expiration is not None: - if not isinstance(expiration, str): - raise SmithyIdentityError( - "Expiration must be an ISO8601 string, received: " - f"{type(expiration).__name__}" - ) try: dt = datetime.fromisoformat(expiration) - except ValueError as e: + except (TypeError, ValueError) as e: raise SmithyIdentityError( - f"Failed to parse credential process expiration: {e}" + "Invalid credential process Expiration; " + f"expected an ISO 8601 string: {e}" ) from e expiration = dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 69dd1953f..352ebc334 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio import json +import traceback from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, Mock, patch @@ -110,7 +111,8 @@ async def test_invalid_expiration_string(): with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) with pytest.raises( - SmithyIdentityError, match="Failed to parse credential process expiration" + SmithyIdentityError, + match="Invalid credential process Expiration; expected an ISO 8601 string", ): await resolver.get_identity(properties={}) @@ -125,7 +127,8 @@ async def test_non_string_expiration(): with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) with pytest.raises( - SmithyIdentityError, match="Expiration must be an ISO8601 string" + SmithyIdentityError, + match="Invalid credential process Expiration; expected an ISO 8601 string", ): await resolver.get_identity(properties={}) @@ -235,13 +238,34 @@ async def test_missing_version(): async def test_invalid_json(): - process = mock_subprocess(0, b"not valid json") + process = mock_subprocess(0, b'{"SecretAccessKey": "json-secret"') + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises( + SmithyIdentityError, + match="Credential process output is not valid JSON at line 1, column", + ) as exc_info: + await resolver.get_identity(properties={}) + + rendered = "".join(traceback.format_exception(exc_info.value)) + assert "json-secret" not in rendered + + +async def test_invalid_utf8(): + process = mock_subprocess(0, b'{"SecretAccessKey": "utf8-secret"}\xff') with patch("asyncio.create_subprocess_exec", return_value=process): resolver = ProcessCredentialsResolver(["mock-process"]) - with pytest.raises(SmithyIdentityError, match="Failed to parse"): + with pytest.raises( + SmithyIdentityError, + match="Credential process output is not valid UTF-8 at byte", + ) as exc_info: await resolver.get_identity(properties={}) + rendered = "".join(traceback.format_exception(exc_info.value)) + assert "utf8-secret" not in rendered + async def test_process_timeout(): process = AsyncMock() From 1d8733da7f4069e8e0910c6f8c4526e061adbd80 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 9 Aug 2026 22:19:16 -0400 Subject: [PATCH 15/15] Address PR feedback --- packages/smithy-aws-core/pyproject.toml | 2 +- .../src/smithy_aws_core/identity/__init__.py | 4 +-- .../identity/chain/providers/process.py | 16 ++++++++-- .../src/smithy_aws_core/identity/process.py | 27 +++++++++++++---- .../identity/chain/providers/test_process.py | 15 +++++----- .../tests/unit/identity/test_process.py | 30 ++++++++++++++++++- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/packages/smithy-aws-core/pyproject.toml b/packages/smithy-aws-core/pyproject.toml index 8439bc665..919bf2ac0 100644 --- a/packages/smithy-aws-core/pyproject.toml +++ b/packages/smithy-aws-core/pyproject.toml @@ -42,7 +42,7 @@ Environment = "smithy_aws_core.identity.chain.providers.environment:EnvironmentC SharedConfig = "smithy_aws_core.identity.chain.providers.shared_config:SharedConfigProvider" ProfileSessionKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileSessionCredentialsProvider" ProfileStaticKeys = "smithy_aws_core.identity.chain.providers.profile:ProfileStaticCredentialsProvider" -ProfileCredentialProcess = "smithy_aws_core.identity.chain.providers.process:ProfileProcessCredentialsProvider" +ProfileCredentialProcess = "smithy_aws_core.identity.chain.providers.process:ProfileCredentialProcessProvider" [build-system] requires = ["hatchling"] diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py index 2fe508952..552cafae6 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/__init__.py @@ -4,7 +4,7 @@ from .chain import IdentityChain, IdentityChainError, UnclaimedSource from .chain.providers.environment import EnvironmentCredentialsProvider -from .chain.providers.process import ProfileProcessCredentialsProvider +from .chain.providers.process import ProfileCredentialProcessProvider from .chain.providers.profile import ( ProfileSessionCredentialsProvider, ProfileStaticCredentialsProvider, @@ -33,7 +33,7 @@ "IdentityChain", "IdentityChainError", "ProcessCredentialsResolver", - "ProfileProcessCredentialsProvider", + "ProfileCredentialProcessProvider", "ProfileSessionCredentialsProvider", "ProfileStaticCredentialsProvider", "SharedConfigProvider", diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py index 0403e0b22..dd9b6e678 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/chain/providers/process.py @@ -3,6 +3,7 @@ import shlex import sys +from smithy_core.exceptions import SmithyError from smithy_core.interfaces.identity import Identity from ...components import AWSCredentialsIdentity @@ -14,6 +15,10 @@ _ACCOUNT_ID = "aws_account_id" +class ProcessConfigurationError(SmithyError): + """Raised when a profile's credential process command is misconfigured.""" + + def _split_process_command( command: str, *, @@ -24,7 +29,12 @@ def _split_process_command( platform = sys.platform if platform == "win32": return _split_windows_command(command) - return shlex.split(command) + try: + return shlex.split(command) + except ValueError as e: + raise ProcessConfigurationError( + f"Could not parse credential process command: {e}" + ) from e def _split_windows_command(command: str) -> list[str]: @@ -76,7 +86,7 @@ def _split_windows_command(command: str) -> list[str]: argument_started = True if in_quotes: - raise ValueError(f"No closing quotation in string: {command}") + raise ProcessConfigurationError(f"No closing quotation in string: {command}") if backslashes: argument.extend("\\" * backslashes) @@ -86,7 +96,7 @@ def _split_windows_command(command: str) -> list[str]: return arguments -class ProfileProcessCredentialsProvider: +class ProfileCredentialProcessProvider: """Adds a process credential resolver configured by the active profile.""" @property diff --git a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py index 0a2ce3d8d..19a20591a 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/identity/process.py @@ -99,18 +99,25 @@ async def get_identity( f"at line {e.lineno}, column {e.colno}: {e.msg}" ) from None + if not isinstance(creds, dict): + raise SmithyIdentityError( + "Credential process output must be a JSON object, " + f"got {type(creds).__name__}" + ) + creds = cast(dict[str, object], creds) + version = creds.get("Version") if version != 1: raise SmithyIdentityError( f"Unsupported version '{version}' for credential process provider, supported versions: 1" ) - access_key_id = creds.get("AccessKeyId") - secret_access_key = creds.get("SecretAccessKey") - session_token = creds.get("SessionToken") - expiration = creds.get("Expiration") + access_key_id = self._get_string_field(creds, "AccessKeyId") + secret_access_key = self._get_string_field(creds, "SecretAccessKey") + session_token = self._get_string_field(creds, "SessionToken") + expiration = self._get_string_field(creds, "Expiration") # Prefer the process output's AccountId, falling back to the profile's # aws_account_id when the process omits it. - account_id = creds.get("AccountId") or self._account_id + account_id = self._get_string_field(creds, "AccountId") or self._account_id if expiration is not None: try: @@ -136,6 +143,16 @@ async def get_identity( ) return self._credentials + @staticmethod + def _get_string_field(creds: dict[str, object], key: str) -> str | None: + value = creds.get(key) + if value is not None and not isinstance(value, str): + raise SmithyIdentityError( + f"Credential process output field '{key}' must be a string, " + f"got {type(value).__name__}" + ) + return value + async def invalidate(self) -> None: """Discard cached credentials so the next resolution reruns the process.""" self._credentials = None diff --git a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py index f6a2b873e..3df8f928f 100644 --- a/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/chain/providers/test_process.py @@ -11,7 +11,8 @@ from smithy_aws_core.config.merged_config import MergedConfig from smithy_aws_core.identity.chain.provider import ChainSetup from smithy_aws_core.identity.chain.providers.process import ( - ProfileProcessCredentialsProvider, + ProcessConfigurationError, + ProfileCredentialProcessProvider, _split_process_command, ) from smithy_aws_core.identity.process import ProcessCredentialsResolver @@ -86,7 +87,7 @@ def test_split_process_command_posix(platform: str) -> None: @pytest.mark.parametrize("platform", ["darwin", "linux", "win32"]) def test_split_process_command_rejects_unclosed_quote(platform: str) -> None: - with pytest.raises(ValueError, match="No closing quotation"): + with pytest.raises(ProcessConfigurationError, match="No closing quotation"): _split_process_command('"credential-helper', platform=platform) @@ -94,7 +95,7 @@ async def test_ignores_non_aws_identity_type( setup_provider: Callable[..., Awaitable[ChainSetup]], merged_config: Callable[..., MergedConfig], ) -> None: - provider = ProfileProcessCredentialsProvider() + provider = ProfileCredentialProcessProvider() setup = await setup_provider( provider, @@ -112,7 +113,7 @@ async def test_ignores_non_aws_identity_type( async def test_requires_active_profile( setup_provider: Callable[..., Awaitable[ChainSetup]], ) -> None: - setup = await setup_provider(ProfileProcessCredentialsProvider()) + setup = await setup_provider(ProfileCredentialProcessProvider()) assert setup.resolvers == () assert not setup.terminal @@ -123,7 +124,7 @@ async def test_missing_process_does_not_register( merged_config: Callable[..., MergedConfig], ) -> None: setup = await setup_provider( - ProfileProcessCredentialsProvider(), + ProfileCredentialProcessProvider(), config_file=merged_config({"default": {}}), profile_name="default", ) @@ -137,7 +138,7 @@ async def test_registers_terminal_resolver( merged_config: Callable[..., MergedConfig], ) -> None: setup = await setup_provider( - ProfileProcessCredentialsProvider(), + ProfileCredentialProcessProvider(), config_file=merged_config( { "default": { @@ -187,7 +188,7 @@ async def test_account_id_falls_back_to_profile_config( merged_config: Callable[..., MergedConfig], ) -> None: setup = await setup_provider( - ProfileProcessCredentialsProvider(), + ProfileCredentialProcessProvider(), config_file=merged_config( { "default": { diff --git a/packages/smithy-aws-core/tests/unit/identity/test_process.py b/packages/smithy-aws-core/tests/unit/identity/test_process.py index 352ebc334..c52d05818 100644 --- a/packages/smithy-aws-core/tests/unit/identity/test_process.py +++ b/packages/smithy-aws-core/tests/unit/identity/test_process.py @@ -128,7 +128,7 @@ async def test_non_string_expiration(): resolver = ProcessCredentialsResolver(["mock-process"]) with pytest.raises( SmithyIdentityError, - match="Invalid credential process Expiration; expected an ISO 8601 string", + match="Credential process output field 'Expiration' must be a string", ): await resolver.get_identity(properties={}) @@ -252,6 +252,34 @@ async def test_invalid_json(): assert "json-secret" not in rendered +async def test_non_dict_output(): + process = mock_subprocess(0, b'["not", "an", "object"]') + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises( + SmithyIdentityError, + match="Credential process output must be a JSON object, got list", + ): + await resolver.get_identity(properties={}) + + +async def test_non_string_field(): + resp_data = dict(DEFAULT_RESPONSE_DATA) + resp_data["AccessKeyId"] = 12345 + + resp_body = json.dumps(resp_data) + process = mock_subprocess(0, resp_body.encode("utf-8")) + + with patch("asyncio.create_subprocess_exec", return_value=process): + resolver = ProcessCredentialsResolver(["mock-process"]) + with pytest.raises( + SmithyIdentityError, + match="Credential process output field 'AccessKeyId' must be a string", + ): + await resolver.get_identity(properties={}) + + async def test_invalid_utf8(): process = mock_subprocess(0, b'{"SecretAccessKey": "utf8-secret"}\xff')