From 987223c5d198ac7f8183e56c7aa7cef2567a28ef Mon Sep 17 00:00:00 2001 From: Brent Champion Date: Thu, 2 Oct 2025 22:59:43 -0400 Subject: [PATCH 1/2] feat: Support all formats of function name in invoke Fixes #8289 --- .gitignore | 2 + samcli/commands/local/invoke/cli.py | 2 + samcli/commands/local/lib/local_lambda.py | 6 +- samcli/lib/utils/name_utils.py | 109 ++++++++++++ .../local_lambda_invoke_service.py | 18 +- tests/unit/lib/utils/test_name_utils.py | 162 ++++++++++++++++++ .../test_local_lambda_invoke_service.py | 96 +++++++++++ 7 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 samcli/lib/utils/name_utils.py create mode 100644 tests/unit/lib/utils/test_name_utils.py diff --git a/.gitignore b/.gitignore index 7fd0531fdc..cddd9db314 100644 --- a/.gitignore +++ b/.gitignore @@ -414,9 +414,11 @@ coverage.xml tests/integration/buildcmd/scratch tests/integration/testdata/buildcmd/Dotnet*/bin tests/integration/testdata/buildcmd/Dotnet*/obj +tests/integration/testdata/buildcmd/Java/**/build/ tests/integration/testdata/invoke/credential_tests/inprocess/dotnet/STS/obj tests/integration/testdata/sync/code/after/dotnet_function/src/HelloWorld/obj/ tests/integration/testdata/sync/code/before/dotnet_function/src/HelloWorld/obj/ +samcli/lib/init/templates/cookiecutter-aws-sam-hello-java-gradle/**/.gradle/ # End of https://www.gitignore.io/api/osx,node,macos,linux,python,windows,pycharm,intellij,sublimetext,visualstudiocode diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 60a9c4580c..73f79fa971 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -190,6 +190,7 @@ def do_cli( # pylint: disable=R0914 from samcli.commands.local.lib.exceptions import NoPrivilegeException, OverridesNotWellDefinedError from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.providers.exceptions import InvalidLayerReference + from samcli.lib.utils.name_utils import InvalidFunctionNameException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported from samcli.local.docker.manager import DockerImagePullFailedException from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -247,6 +248,7 @@ def do_cli( # pylint: disable=R0914 except ( InvalidSamDocumentException, OverridesNotWellDefinedError, + InvalidFunctionNameException, InvalidLayerReference, InvalidIntermediateImageError, DebuggingNotSupported, diff --git a/samcli/commands/local/lib/local_lambda.py b/samcli/commands/local/lib/local_lambda.py index 2375a92669..685ebca073 100644 --- a/samcli/commands/local/lib/local_lambda.py +++ b/samcli/commands/local/lib/local_lambda.py @@ -23,6 +23,7 @@ from samcli.lib.utils.architecture import validate_architecture_runtime from samcli.lib.utils.codeuri import resolve_code_path from samcli.lib.utils.colors import Colored +from samcli.lib.utils.name_utils import normalize_lambda_function_name from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.container import ContainerConnectionTimeoutException, ContainerResponseException @@ -127,8 +128,11 @@ def invoke( When we cannot find a function with the given name """ + # Normalize function identifier from ARN if provided + normalized_function_identifier = normalize_lambda_function_name(function_identifier) + # Generate the correct configuration based on given inputs - function = self.provider.get(function_identifier) + function = self.provider.get(normalized_function_identifier) if not function: all_function_full_paths = [f.full_path for f in self.provider.get_all()] diff --git a/samcli/lib/utils/name_utils.py b/samcli/lib/utils/name_utils.py new file mode 100644 index 0000000000..0ae61c7319 --- /dev/null +++ b/samcli/lib/utils/name_utils.py @@ -0,0 +1,109 @@ +""" +Utilities for normalizing function names and identifiers +""" + +import re + +from samcli.lib.utils.arn_utils import ARNParts, InvalidArnValue + +# Constants for ARN parsing +PARTIAL_ARN_MIN_PARTS = 3 +FUNCTION_ALIAS_PARTS = 2 + +# AWS Lambda function name validation pattern (same as AWS Lambda service) +LAMBDA_FUNCTION_NAME_PATTERN = ( + r"^(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:)?" + r"(\d{12}:)?(function:)?([a-zA-Z0-9-_\.]+)(:(\$LATEST(\.PUBLISHED)?|[a-zA-Z0-9-_]+))?$" +) + + +class InvalidFunctionNameException(Exception): + """Exception raised when function name doesn't match AWS Lambda validation pattern""" + + pass + + +def normalize_lambda_function_name(function_identifier: str) -> str: + """ + Normalize a Lambda function identifier by extracting the function name from various formats. + + AWS Lambda supports multiple function identifier formats as documented in the AWS CLI/SDK: + + Name formats: + - Function name: my-function (name-only), my-function:v1 (with alias) + - Function ARN: arn:aws:lambda:us-west-2:123456789012:function:my-function + - Partial ARN: 123456789012:function:my-function + + This function normalizes all these formats to extract just the function name portion, + which is what SAM CLI uses internally for function lookup. + + Parameters + ---------- + function_identifier : str + The function identifier in any of the supported formats + + Returns + ------- + str + The normalized function name + + Raises + ------ + InvalidFunctionNameException + If the function identifier doesn't match AWS Lambda's validation pattern + + Examples + -------- + >>> normalize_lambda_function_name("my-function") + "my-function" + >>> normalize_lambda_function_name("my-function:v1") + "my-function" + >>> normalize_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-function") + "my-function" + >>> normalize_lambda_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST") + "my-function" + >>> normalize_lambda_function_name("123456789012:function:my-function") + "my-function" + """ + if not re.match(LAMBDA_FUNCTION_NAME_PATTERN, function_identifier): + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + + # Handle full ARN format: arn:aws:lambda:region:account-id:function:function-name[:version] + if function_identifier.startswith("arn:"): + try: + arn_parts = ARNParts(function_identifier) + # Check if it's a Lambda ARN with function resource type + if arn_parts.service == "lambda" and arn_parts.resource_type == "function": + # For Lambda ARNs, the function name is in resource_id + # Handle versioned functions by splitting on ':' + function_name = arn_parts.resource_id.split(":")[0] if arn_parts.resource_id else "" + return function_name if function_name else function_identifier + except InvalidArnValue: + # If ARN parsing fails, return the original input + pass + + # Handle partial ARN format: account-id:function:function-name[:version] + # This format has at least 3 parts separated by colons + elif ":" in function_identifier and len(function_identifier.split(":")) >= PARTIAL_ARN_MIN_PARTS: + parts = function_identifier.split(":") + # Check if it matches the partial ARN pattern: account-id:function:function-name[:version] + if len(parts) >= PARTIAL_ARN_MIN_PARTS and parts[1] == "function" and parts[2]: + # Extract function name (3rd part) and remove any version suffix + function_name = parts[2] + return function_name if function_name else function_identifier + + # Handle function name with alias: my-function:alias + # This is a simple function name with a single colon for alias/version + # But exclude partial ARN patterns like "account:function" (missing function name) + elif ":" in function_identifier: + parts = function_identifier.split(":") + if len(parts) == FUNCTION_ALIAS_PARTS and parts[1] != "function" and parts[0]: + # Return just the function name part (before the colon) if it's not empty + return parts[0] + + # Handle plain function name: my-function + return function_identifier diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py index 51030f6806..3887dca99f 100644 --- a/samcli/local/lambda_service/local_lambda_invoke_service.py +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -8,6 +8,7 @@ from werkzeug.routing import BaseConverter from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError +from samcli.lib.utils.name_utils import InvalidFunctionNameException, normalize_lambda_function_name from samcli.lib.utils.stream_writer import StreamWriter from samcli.local.docker.exceptions import DockerContainerCreationFailedException from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -151,7 +152,7 @@ def _invoke_request_handler(self, function_name): Parameters ---------- function_name str - Name of the function to invoke + Name or ARN of the function to invoke Returns ------- @@ -166,15 +167,24 @@ def _invoke_request_handler(self, function_name): request_data = request_data.decode("utf-8") + # Normalize function name from ARN if provided + try: + normalized_function_name = normalize_lambda_function_name(function_name) + except InvalidFunctionNameException as e: + LOG.error("Invalid function name: %s", str(e)) + return LambdaErrorResponses.invalid_request_content(str(e)) + stdout_stream_string = io.StringIO() stdout_stream_bytes = io.BytesIO() stdout_stream_writer = StreamWriter(stdout_stream_string, stdout_stream_bytes, auto_flush=True) try: - self.lambda_runner.invoke(function_name, request_data, stdout=stdout_stream_writer, stderr=self.stderr) + self.lambda_runner.invoke( + normalized_function_name, request_data, stdout=stdout_stream_writer, stderr=self.stderr + ) except FunctionNotFound: - LOG.debug("%s was not found to invoke.", function_name) - return LambdaErrorResponses.resource_not_found(function_name) + LOG.debug("%s was not found to invoke.", normalized_function_name) + return LambdaErrorResponses.resource_not_found(normalized_function_name) except UnsupportedInlineCodeError: return LambdaErrorResponses.not_implemented_locally( "Inline code is not supported for sam local commands. Please write your code in a separate file." diff --git a/tests/unit/lib/utils/test_name_utils.py b/tests/unit/lib/utils/test_name_utils.py new file mode 100644 index 0000000000..100d586b5f --- /dev/null +++ b/tests/unit/lib/utils/test_name_utils.py @@ -0,0 +1,162 @@ +""" +Tests for samcli.lib.utils.name_utils module +""" + +import pytest + +from samcli.lib.utils.name_utils import normalize_lambda_function_name, InvalidFunctionNameException + + +class TestNormalizeLambdaFunctionName: + """Test cases for normalize_lambda_function_name function""" + + def test_returns_function_name_unchanged_when_not_arn(self): + """Test that regular function names are returned unchanged""" + function_name = "my-function" + result = normalize_lambda_function_name(function_name) + assert result == function_name + + def test_extracts_function_name_from_basic_lambda_arn(self): + """Test extracting function name from basic Lambda ARN""" + arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function" + result = normalize_lambda_function_name(arn) + assert result == "my-function" + + def test_extracts_function_name_from_versioned_lambda_arn(self): + """Test extracting function name from versioned Lambda ARN""" + arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST" + result = normalize_lambda_function_name(arn) + assert result == "my-function" + + def test_extracts_function_name_from_numbered_version_lambda_arn(self): + """Test extracting function name from numbered version Lambda ARN""" + arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function:1" + result = normalize_lambda_function_name(arn) + assert result == "my-function" + + def test_extracts_function_name_from_different_partition(self): + """Test extracting function name from Lambda ARN in different partition""" + arn = "arn:aws-cn:lambda:cn-north-1:123456789012:function:my-function" + result = normalize_lambda_function_name(arn) + assert result == "my-function" + + def test_extracts_function_name_from_partial_arn(self): + """Test extracting function name from partial ARN format""" + partial_arn = "123456789012:function:my-function" + result = normalize_lambda_function_name(partial_arn) + assert result == "my-function" + + def test_extracts_function_name_from_partial_arn_with_version(self): + """Test extracting function name from partial ARN with version""" + partial_arn = "123456789012:function:my-function:$LATEST" + result = normalize_lambda_function_name(partial_arn) + assert result == "my-function" + + def test_extracts_function_name_from_partial_arn_with_numeric_version(self): + """Test extracting function name from partial ARN with numeric version""" + partial_arn = "123456789012:function:my-function:1" + result = normalize_lambda_function_name(partial_arn) + assert result == "my-function" + + def test_extracts_function_name_with_alias(self): + """Test extracting function name from function name with alias""" + function_with_alias = "my-function:v1" + result = normalize_lambda_function_name(function_with_alias) + assert result == "my-function" + + def test_extracts_function_name_with_latest_alias(self): + """Test extracting function name from function name with $LATEST alias""" + function_with_alias = "my-function:$LATEST" + result = normalize_lambda_function_name(function_with_alias) + assert result == "my-function" + + def test_raises_exception_for_non_lambda_arn(self): + """Test that non-Lambda ARNs raise InvalidFunctionNameException""" + arn = "arn:aws:s3:::my-bucket/my-key" + with pytest.raises(InvalidFunctionNameException): + normalize_lambda_function_name(arn) + + def test_returns_unchanged_for_invalid_arn(self): + """Test that invalid ARNs are returned unchanged""" + invalid_arn = "not-an-arn" + result = normalize_lambda_function_name(invalid_arn) + assert result == invalid_arn + + def test_raises_exception_for_malformed_arn(self): + """Test that malformed ARNs raise InvalidFunctionNameException""" + malformed_arn = "arn:aws:lambda" + with pytest.raises(InvalidFunctionNameException): + normalize_lambda_function_name(malformed_arn) + + def test_returns_unchanged_for_invalid_partial_arn(self): + """Test that invalid partial ARNs are returned unchanged""" + invalid_partial = "123456789012:invalid:my-function" + result = normalize_lambda_function_name(invalid_partial) + assert result == invalid_partial + + def test_handles_function_name_with_hyphens_and_underscores(self): + """Test handling function names with special characters""" + function_name = "my-function_name" + result = normalize_lambda_function_name(function_name) + assert result == function_name + + def test_extracts_function_name_with_special_characters_from_arn(self): + """Test extracting function name with special characters from ARN""" + arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function_name-test" + result = normalize_lambda_function_name(arn) + assert result == "my-function_name-test" + + def test_extracts_function_name_with_special_characters_from_partial_arn(self): + """Test extracting function name with special characters from partial ARN""" + partial_arn = "123456789012:function:my-function_name-test" + result = normalize_lambda_function_name(partial_arn) + assert result == "my-function_name-test" + + def test_handles_complex_function_names(self): + """Test handling complex function names with multiple special characters""" + complex_names = [ + "my-function-with-many-hyphens", + "my_function_with_underscores", + "MyFunctionWithCamelCase", + "function123WithNumbers", + "function-with_mixed-chars123", + ] + + for name in complex_names: + # Test plain function name + result = normalize_lambda_function_name(name) + assert result == name + + # Test with alias + result = normalize_lambda_function_name(f"{name}:v1") + assert result == name + + # Test with full ARN + arn = f"arn:aws:lambda:us-east-1:123456789012:function:{name}" + result = normalize_lambda_function_name(arn) + assert result == name + + # Test with partial ARN + partial_arn = f"123456789012:function:{name}" + result = normalize_lambda_function_name(partial_arn) + assert result == name + + def test_raises_exception_for_invalid_function_names(self): + """Test that invalid function names raise InvalidFunctionNameException""" + invalid_names = [ + ":HelloWorld", # Starts with colon + "HelloWorld:", # Ends with colon (empty alias) + "", # Empty string + ":", # Just colon + "function-with-invalid-chars!", # Invalid characters + "123456789012:function:", # Partial ARN with empty function name + ] + + for invalid_name in invalid_names: + with pytest.raises(InvalidFunctionNameException) as exc_info: + normalize_lambda_function_name(invalid_name) + + # Check that the error message matches AWS Lambda's format + assert "1 validation error detected" in str(exc_info.value) + assert f"Value '{invalid_name}' at 'functionName'" in str(exc_info.value) + assert "failed to satisfy constraint" in str(exc_info.value) diff --git a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py index af5a2a5aa2..66231e3e7a 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -1,11 +1,14 @@ from unittest import TestCase from unittest.mock import Mock, patch, ANY, call +import pytest + from samcli.local.docker.exceptions import DockerContainerCreationFailedException from samcli.local.lambda_service import local_lambda_invoke_service from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService, FunctionNamePathConverter from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError +from samcli.lib.utils.name_utils import normalize_lambda_function_name, InvalidFunctionNameException class TestLocalLambdaService(TestCase): @@ -332,3 +335,96 @@ def test_path_converter_matches_function_full_path(self): path_converter = FunctionNamePathConverter(map) full_path = "parent_stack/function_id" self.assertRegex(full_path, path_converter.regex) + + +class TestFunctionNameNormalization(TestCase): + def test_normalize_function_name_with_regular_name(self): + """Test that regular function names are returned unchanged""" + result = normalize_lambda_function_name("my-function") + self.assertEqual(result, "my-function") + + def test_normalize_function_name_with_basic_arn(self): + """Test ARN normalization with basic ARN format""" + arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function" + result = normalize_lambda_function_name(arn) + self.assertEqual(result, "my-function") + + def test_normalize_function_name_with_arn_and_version(self): + """Test ARN normalization with version qualifier""" + arn = "arn:aws:lambda:us-west-2:123456789012:function:my-function:$LATEST" + result = normalize_lambda_function_name(arn) + self.assertEqual(result, "my-function") + + def test_normalize_function_name_with_arn_and_numeric_version(self): + """Test ARN normalization with numeric version qualifier""" + arn = "arn:aws:lambda:eu-west-1:123456789012:function:my-function:1" + result = normalize_lambda_function_name(arn) + self.assertEqual(result, "my-function") + + def test_normalize_function_name_with_complex_function_name(self): + """Test ARN normalization with complex function name""" + arn = "arn:aws:lambda:ap-southeast-1:123456789012:function:my-complex-function-name" + result = normalize_lambda_function_name(arn) + self.assertEqual(result, "my-complex-function-name") + + def test_normalize_function_name_with_malformed_arn(self): + """Test that malformed ARNs are returned unchanged""" + malformed_arn = "arn:aws:lambda:us-east-1:123456789012" # Missing function part + result = normalize_lambda_function_name(malformed_arn) + self.assertEqual(result, malformed_arn) + + def test_normalize_function_name_with_non_lambda_arn(self): + """Test that non-Lambda ARNs raise InvalidFunctionNameException""" + s3_arn = "arn:aws:s3:::my-bucket" + with pytest.raises(InvalidFunctionNameException): + normalize_lambda_function_name(s3_arn) + + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response") + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser") + def test_invoke_request_handler_with_arn(self, lambda_output_parser_mock, service_response_mock): + """Test that invoke request handler correctly normalizes ARN to function name""" + lambda_output_parser_mock.get_lambda_output.return_value = "hello world", False + service_response_mock.return_value = "request response" + + request_mock = Mock() + request_mock.get_data.return_value = b"{}" + local_lambda_invoke_service.request = request_mock + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host="localhost") + + # Call with ARN instead of function name + arn = "arn:aws:lambda:us-east-1:123456789012:function:HelloWorld" + response = service._invoke_request_handler(function_name=arn) + + self.assertEqual(response, "request response") + + # Verify that the lambda runner was called with the normalized function name + lambda_runner_mock.invoke.assert_called_once_with("HelloWorld", "{}", stdout=ANY, stderr=None) + service_response_mock.assert_called_once_with("hello world", {"Content-Type": "application/json"}, 200) + + @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses") + def test_invoke_request_handler_function_not_found_with_arn(self, lambda_error_responses_mock): + """Test that error handling uses normalized function name when ARN is provided""" + request_mock = Mock() + request_mock.get_data.return_value = b"{}" + local_lambda_invoke_service.request = request_mock + + lambda_runner_mock = Mock() + lambda_runner_mock.invoke.side_effect = FunctionNotFound + + lambda_error_responses_mock.resource_not_found.return_value = "Couldn't find Lambda" + + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host="localhost") + + # Call with ARN instead of function name + arn = "arn:aws:lambda:us-east-1:123456789012:function:NotFound" + response = service._invoke_request_handler(function_name=arn) + + self.assertEqual(response, "Couldn't find Lambda") + + # Verify that the lambda runner was called with the normalized function name + lambda_runner_mock.invoke.assert_called_once_with("NotFound", "{}", stdout=ANY, stderr=None) + + # Verify that error response uses the normalized function name + lambda_error_responses_mock.resource_not_found.assert_called_once_with("NotFound") From 554bcb3dc32acd54069ab799cdd876785a887bd6 Mon Sep 17 00:00:00 2001 From: Brent Champion Date: Fri, 3 Oct 2025 10:54:52 -0400 Subject: [PATCH 2/2] fix: throw ValidationException instead of InvalidRequestContent when function name is invalid --- samcli/lib/utils/name_utils.py | 52 +++-- .../lambda_service/lambda_error_responses.py | 26 +++ .../local_lambda_invoke_service.py | 2 +- tests/unit/lib/utils/test_name_utils.py | 196 +++++------------- .../test_lambda_error_responses.py | 13 +- .../test_local_lambda_invoke_service.py | 47 +---- 6 files changed, 124 insertions(+), 212 deletions(-) diff --git a/samcli/lib/utils/name_utils.py b/samcli/lib/utils/name_utils.py index 0ae61c7319..d3d0e88e19 100644 --- a/samcli/lib/utils/name_utils.py +++ b/samcli/lib/utils/name_utils.py @@ -9,11 +9,10 @@ # Constants for ARN parsing PARTIAL_ARN_MIN_PARTS = 3 FUNCTION_ALIAS_PARTS = 2 +MIN_ARN_PARTS = 5 # Minimum parts for a valid ARN structure -# AWS Lambda function name validation pattern (same as AWS Lambda service) LAMBDA_FUNCTION_NAME_PATTERN = ( - r"^(arn:(aws[a-zA-Z-]*)?:lambda:)?((eusc-)?[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\d{1}:)?" - r"(\d{12}:)?(function:)?([a-zA-Z0-9-_\.]+)(:(\$LATEST(\.PUBLISHED)?|[a-zA-Z0-9-_]+))?$" + r"^(arn:[^:]+:lambda:[^:]*:\d{12}:function:|\d{12}:function:)?[a-zA-Z0-9-_\.]+(:[\w$-]+)?$" ) @@ -65,13 +64,6 @@ def normalize_lambda_function_name(function_identifier: str) -> str: >>> normalize_lambda_function_name("123456789012:function:my-function") "my-function" """ - if not re.match(LAMBDA_FUNCTION_NAME_PATTERN, function_identifier): - raise InvalidFunctionNameException( - f"1 validation error detected: Value '{function_identifier}' at 'functionName' " - f"failed to satisfy constraint: Member must satisfy regular expression pattern: " - f"{LAMBDA_FUNCTION_NAME_PATTERN}" - ) - # Handle full ARN format: arn:aws:lambda:region:account-id:function:function-name[:version] if function_identifier.startswith("arn:"): try: @@ -82,9 +74,23 @@ def normalize_lambda_function_name(function_identifier: str) -> str: # Handle versioned functions by splitting on ':' function_name = arn_parts.resource_id.split(":")[0] if arn_parts.resource_id else "" return function_name if function_name else function_identifier + elif arn_parts.service != "lambda": + # Non-Lambda ARNs should raise an exception + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) except InvalidArnValue: - # If ARN parsing fails, return the original input - pass + # Very malformed ARNs (like "arn:aws:lambda") should raise an exception + if len(function_identifier.split(":")) < MIN_ARN_PARTS: # ARNs with less than 5 parts are too malformed + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + # Other malformed ARNs are returned unchanged + return function_identifier # Handle partial ARN format: account-id:function:function-name[:version] # This format has at least 3 parts separated by colons @@ -95,15 +101,33 @@ def normalize_lambda_function_name(function_identifier: str) -> str: # Extract function name (3rd part) and remove any version suffix function_name = parts[2] return function_name if function_name else function_identifier + # Invalid partial ARNs (like empty function name) should raise exception if they look like partial ARNs + elif len(parts) >= PARTIAL_ARN_MIN_PARTS and parts[1] == "function": + # This looks like a partial ARN but has invalid structure + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + # Other invalid partial ARNs are returned unchanged + return function_identifier # Handle function name with alias: my-function:alias # This is a simple function name with a single colon for alias/version # But exclude partial ARN patterns like "account:function" (missing function name) elif ":" in function_identifier: parts = function_identifier.split(":") - if len(parts) == FUNCTION_ALIAS_PARTS and parts[1] != "function" and parts[0]: - # Return just the function name part (before the colon) if it's not empty + if len(parts) == FUNCTION_ALIAS_PARTS and parts[1] != "function" and parts[0] and parts[1]: + # Return just the function name part (before the colon) if both parts are not empty return parts[0] + # Validate plain function names against the pattern + if not re.match(LAMBDA_FUNCTION_NAME_PATTERN, function_identifier): + raise InvalidFunctionNameException( + f"1 validation error detected: Value '{function_identifier}' at 'functionName' " + f"failed to satisfy constraint: Member must satisfy regular expression pattern: " + f"{LAMBDA_FUNCTION_NAME_PATTERN}" + ) + # Handle plain function name: my-function return function_identifier diff --git a/samcli/local/lambda_service/lambda_error_responses.py b/samcli/local/lambda_service/lambda_error_responses.py index 38b91bf294..c447157ca6 100644 --- a/samcli/local/lambda_service/lambda_error_responses.py +++ b/samcli/local/lambda_service/lambda_error_responses.py @@ -19,6 +19,9 @@ class LambdaErrorResponses: # The request body could not be parsed as JSON. InvalidRequestContentException = ("InvalidRequestContent", 400) + # One or more parameters values were invalid. + ValidationException = ("ValidationException", 400) + NotImplementedException = ("NotImplemented", 501) ContainerCreationFailed = ("ContainerCreationFailed", 501) @@ -85,6 +88,29 @@ def invalid_request_content(message): exception_tuple[1], ) + @staticmethod + def validation_exception(message): + """ + Creates a Lambda Service ValidationException Response + + Parameters + ---------- + message str + Message to be added to the body of the response + + Returns + ------- + Flask.Response + A response object representing the ValidationException Error + """ + exception_tuple = LambdaErrorResponses.ValidationException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.USER_ERROR, message), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1], + ) + @staticmethod def unsupported_media_type(content_type): """ diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py index 3887dca99f..0b635bf50f 100644 --- a/samcli/local/lambda_service/local_lambda_invoke_service.py +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -172,7 +172,7 @@ def _invoke_request_handler(self, function_name): normalized_function_name = normalize_lambda_function_name(function_name) except InvalidFunctionNameException as e: LOG.error("Invalid function name: %s", str(e)) - return LambdaErrorResponses.invalid_request_content(str(e)) + return LambdaErrorResponses.validation_exception(str(e)) stdout_stream_string = io.StringIO() stdout_stream_bytes = io.BytesIO() diff --git a/tests/unit/lib/utils/test_name_utils.py b/tests/unit/lib/utils/test_name_utils.py index 100d586b5f..8d8793f3c4 100644 --- a/tests/unit/lib/utils/test_name_utils.py +++ b/tests/unit/lib/utils/test_name_utils.py @@ -2,161 +2,59 @@ Tests for samcli.lib.utils.name_utils module """ -import pytest +from unittest import TestCase + +from parameterized import parameterized from samcli.lib.utils.name_utils import normalize_lambda_function_name, InvalidFunctionNameException -class TestNormalizeLambdaFunctionName: +class TestNormalizeLambdaFunctionName(TestCase): """Test cases for normalize_lambda_function_name function""" - def test_returns_function_name_unchanged_when_not_arn(self): - """Test that regular function names are returned unchanged""" - function_name = "my-function" - result = normalize_lambda_function_name(function_name) - assert result == function_name - - def test_extracts_function_name_from_basic_lambda_arn(self): - """Test extracting function name from basic Lambda ARN""" - arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function" - result = normalize_lambda_function_name(arn) - assert result == "my-function" - - def test_extracts_function_name_from_versioned_lambda_arn(self): - """Test extracting function name from versioned Lambda ARN""" - arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST" - result = normalize_lambda_function_name(arn) - assert result == "my-function" - - def test_extracts_function_name_from_numbered_version_lambda_arn(self): - """Test extracting function name from numbered version Lambda ARN""" - arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function:1" - result = normalize_lambda_function_name(arn) - assert result == "my-function" - - def test_extracts_function_name_from_different_partition(self): - """Test extracting function name from Lambda ARN in different partition""" - arn = "arn:aws-cn:lambda:cn-north-1:123456789012:function:my-function" - result = normalize_lambda_function_name(arn) - assert result == "my-function" - - def test_extracts_function_name_from_partial_arn(self): - """Test extracting function name from partial ARN format""" - partial_arn = "123456789012:function:my-function" - result = normalize_lambda_function_name(partial_arn) - assert result == "my-function" - - def test_extracts_function_name_from_partial_arn_with_version(self): - """Test extracting function name from partial ARN with version""" - partial_arn = "123456789012:function:my-function:$LATEST" - result = normalize_lambda_function_name(partial_arn) - assert result == "my-function" - - def test_extracts_function_name_from_partial_arn_with_numeric_version(self): - """Test extracting function name from partial ARN with numeric version""" - partial_arn = "123456789012:function:my-function:1" - result = normalize_lambda_function_name(partial_arn) - assert result == "my-function" - - def test_extracts_function_name_with_alias(self): - """Test extracting function name from function name with alias""" - function_with_alias = "my-function:v1" - result = normalize_lambda_function_name(function_with_alias) - assert result == "my-function" - - def test_extracts_function_name_with_latest_alias(self): - """Test extracting function name from function name with $LATEST alias""" - function_with_alias = "my-function:$LATEST" - result = normalize_lambda_function_name(function_with_alias) - assert result == "my-function" - - def test_raises_exception_for_non_lambda_arn(self): - """Test that non-Lambda ARNs raise InvalidFunctionNameException""" - arn = "arn:aws:s3:::my-bucket/my-key" - with pytest.raises(InvalidFunctionNameException): - normalize_lambda_function_name(arn) - - def test_returns_unchanged_for_invalid_arn(self): - """Test that invalid ARNs are returned unchanged""" - invalid_arn = "not-an-arn" - result = normalize_lambda_function_name(invalid_arn) - assert result == invalid_arn - - def test_raises_exception_for_malformed_arn(self): - """Test that malformed ARNs raise InvalidFunctionNameException""" - malformed_arn = "arn:aws:lambda" - with pytest.raises(InvalidFunctionNameException): - normalize_lambda_function_name(malformed_arn) - - def test_returns_unchanged_for_invalid_partial_arn(self): - """Test that invalid partial ARNs are returned unchanged""" - invalid_partial = "123456789012:invalid:my-function" - result = normalize_lambda_function_name(invalid_partial) - assert result == invalid_partial - - def test_handles_function_name_with_hyphens_and_underscores(self): - """Test handling function names with special characters""" - function_name = "my-function_name" - result = normalize_lambda_function_name(function_name) - assert result == function_name - - def test_extracts_function_name_with_special_characters_from_arn(self): - """Test extracting function name with special characters from ARN""" - arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function_name-test" - result = normalize_lambda_function_name(arn) - assert result == "my-function_name-test" - - def test_extracts_function_name_with_special_characters_from_partial_arn(self): - """Test extracting function name with special characters from partial ARN""" - partial_arn = "123456789012:function:my-function_name-test" - result = normalize_lambda_function_name(partial_arn) - assert result == "my-function_name-test" - - def test_handles_complex_function_names(self): - """Test handling complex function names with multiple special characters""" - complex_names = [ - "my-function-with-many-hyphens", - "my_function_with_underscores", - "MyFunctionWithCamelCase", - "function123WithNumbers", - "function-with_mixed-chars123", + @parameterized.expand( + [ + # Simple function names (pass-through) + ("my-function", "my-function"), + ("function-with_mixed-chars123", "function-with_mixed-chars123"), + # Full ARNs with versions/aliases + ("arn:aws:lambda:us-east-1:123456789012:function:my-function", "my-function"), + ("arn:aws:lambda:us-east-1:123456789012:function:my-function:$LATEST", "my-function"), + ("arn:aws:lambda:us-east-1:123456789012:function:my-function:1", "my-function"), + ("arn:aws-cn:lambda:cn-north-1:123456789012:function:my-function", "my-function"), + # Partial ARNs + ("123456789012:function:my-function", "my-function"), + ("123456789012:function:my-function:$LATEST", "my-function"), + # Function names with versions/aliases + ("my-function:$LATEST", "my-function"), + ("my-function:v1", "my-function"), + # Invalid partial ARN format (should pass through) + ("123456789012:invalid:my-function", "123456789012:invalid:my-function"), ] - - for name in complex_names: - # Test plain function name - result = normalize_lambda_function_name(name) - assert result == name - - # Test with alias - result = normalize_lambda_function_name(f"{name}:v1") - assert result == name - - # Test with full ARN - arn = f"arn:aws:lambda:us-east-1:123456789012:function:{name}" - result = normalize_lambda_function_name(arn) - assert result == name - - # Test with partial ARN - partial_arn = f"123456789012:function:{name}" - result = normalize_lambda_function_name(partial_arn) - assert result == name - - def test_raises_exception_for_invalid_function_names(self): - """Test that invalid function names raise InvalidFunctionNameException""" - invalid_names = [ - ":HelloWorld", # Starts with colon - "HelloWorld:", # Ends with colon (empty alias) - "", # Empty string - ":", # Just colon - "function-with-invalid-chars!", # Invalid characters - "123456789012:function:", # Partial ARN with empty function name + ) + def test_normalize_lambda_function_name(self, input_name, expected_output): + """Test normalize_lambda_function_name with various inputs""" + result = normalize_lambda_function_name(input_name) + self.assertEqual(result, expected_output) + + @parameterized.expand( + [ + ("arn:aws:s3:::my-bucket/my-key",), + ("arn:aws:lambda",), + (":HelloWorld",), + ("HelloWorld:",), + ("",), + (":",), + ("function-with-invalid-chars!",), + ("123456789012:function:",), ] + ) + def test_raises_exception_for_invalid_function_names(self, invalid_name): + """Test that invalid function names raise InvalidFunctionNameException""" + with self.assertRaises(InvalidFunctionNameException) as exc_info: + normalize_lambda_function_name(invalid_name) - for invalid_name in invalid_names: - with pytest.raises(InvalidFunctionNameException) as exc_info: - normalize_lambda_function_name(invalid_name) - - # Check that the error message matches AWS Lambda's format - assert "1 validation error detected" in str(exc_info.value) - assert f"Value '{invalid_name}' at 'functionName'" in str(exc_info.value) - assert "failed to satisfy constraint" in str(exc_info.value) + # Check that the error message matches AWS Lambda's format + self.assertIn("1 validation error detected", str(exc_info.exception)) + self.assertIn(f"Value '{invalid_name}' at 'functionName'", str(exc_info.exception)) + self.assertIn("failed to satisfy constraint", str(exc_info.exception)) diff --git a/tests/unit/local/lambda_service/test_lambda_error_responses.py b/tests/unit/local/lambda_service/test_lambda_error_responses.py index 47190f0ea4..fa70deb0f0 100644 --- a/tests/unit/local/lambda_service/test_lambda_error_responses.py +++ b/tests/unit/local/lambda_service/test_lambda_error_responses.py @@ -26,9 +26,18 @@ def test_invalid_request_content(self, service_response_mock): response = LambdaErrorResponses.invalid_request_content("InvalidRequestContent") self.assertEqual(response, "InvalidRequestContent") + + @patch("samcli.local.services.base_local_service.BaseLocalService.service_response") + def test_validation_exception(self, service_response_mock): + service_response_mock.return_value = "ValidationException" + + response = LambdaErrorResponses.validation_exception("ValidationException") + + self.assertEqual(response, "ValidationException") + service_response_mock.assert_called_once_with( - '{"Type": "User", "Message": "InvalidRequestContent"}', - {"x-amzn-errortype": "InvalidRequestContent", "Content-Type": "application/json"}, + '{"Type": "User", "Message": "ValidationException"}', + {"x-amzn-errortype": "ValidationException", "Content-Type": "application/json"}, 400, ) diff --git a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py index 66231e3e7a..3f4f22a261 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -1,14 +1,12 @@ from unittest import TestCase from unittest.mock import Mock, patch, ANY, call -import pytest - from samcli.local.docker.exceptions import DockerContainerCreationFailedException from samcli.local.lambda_service import local_lambda_invoke_service from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService, FunctionNamePathConverter from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.commands.local.lib.exceptions import UnsupportedInlineCodeError -from samcli.lib.utils.name_utils import normalize_lambda_function_name, InvalidFunctionNameException +from samcli.lib.utils.name_utils import InvalidFunctionNameException class TestLocalLambdaService(TestCase): @@ -336,49 +334,6 @@ def test_path_converter_matches_function_full_path(self): full_path = "parent_stack/function_id" self.assertRegex(full_path, path_converter.regex) - -class TestFunctionNameNormalization(TestCase): - def test_normalize_function_name_with_regular_name(self): - """Test that regular function names are returned unchanged""" - result = normalize_lambda_function_name("my-function") - self.assertEqual(result, "my-function") - - def test_normalize_function_name_with_basic_arn(self): - """Test ARN normalization with basic ARN format""" - arn = "arn:aws:lambda:us-east-1:123456789012:function:my-function" - result = normalize_lambda_function_name(arn) - self.assertEqual(result, "my-function") - - def test_normalize_function_name_with_arn_and_version(self): - """Test ARN normalization with version qualifier""" - arn = "arn:aws:lambda:us-west-2:123456789012:function:my-function:$LATEST" - result = normalize_lambda_function_name(arn) - self.assertEqual(result, "my-function") - - def test_normalize_function_name_with_arn_and_numeric_version(self): - """Test ARN normalization with numeric version qualifier""" - arn = "arn:aws:lambda:eu-west-1:123456789012:function:my-function:1" - result = normalize_lambda_function_name(arn) - self.assertEqual(result, "my-function") - - def test_normalize_function_name_with_complex_function_name(self): - """Test ARN normalization with complex function name""" - arn = "arn:aws:lambda:ap-southeast-1:123456789012:function:my-complex-function-name" - result = normalize_lambda_function_name(arn) - self.assertEqual(result, "my-complex-function-name") - - def test_normalize_function_name_with_malformed_arn(self): - """Test that malformed ARNs are returned unchanged""" - malformed_arn = "arn:aws:lambda:us-east-1:123456789012" # Missing function part - result = normalize_lambda_function_name(malformed_arn) - self.assertEqual(result, malformed_arn) - - def test_normalize_function_name_with_non_lambda_arn(self): - """Test that non-Lambda ARNs raise InvalidFunctionNameException""" - s3_arn = "arn:aws:s3:::my-bucket" - with pytest.raises(InvalidFunctionNameException): - normalize_lambda_function_name(s3_arn) - @patch("samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response") @patch("samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser") def test_invoke_request_handler_with_arn(self, lambda_output_parser_mock, service_response_mock):