From 35e50fd3cd3fbb204325d7b875d98da14e943e13 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Wed, 13 May 2026 00:12:13 -0700 Subject: [PATCH 1/2] fix: avoid TypeError when CommaDelimitedList parameter name collides with Resource/Output logical ID In resolve_attribute(), get_translation() can return a list when a CommaDelimitedList parameter shares its name with a Resource or Output logical ID. Using that list as a dict key raised "TypeError: unhashable type: 'list'" and broke `sam deploy --guided`. Only use the translated key when it resolves to a string; otherwise fall back to the original key. This preserves the existing logical-ID renaming behavior for Resources while preventing the crash on name collisions. Fixes #8627 --- .../intrinsic_property_resolver.py | 7 +- .../test_intrinsic_resolver.py | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/samcli/lib/intrinsic_resolver/intrinsic_property_resolver.py b/samcli/lib/intrinsic_resolver/intrinsic_property_resolver.py index aecc2a85aad..5e2ffb1cce8 100644 --- a/samcli/lib/intrinsic_resolver/intrinsic_property_resolver.py +++ b/samcli/lib/intrinsic_resolver/intrinsic_property_resolver.py @@ -271,7 +271,12 @@ def resolve_attribute(self, cloud_formation_property, ignore_errors=False): """ processed_dict = OrderedDict() for key, val in cloud_formation_property.items(): - processed_key = self._symbol_resolver.get_translation(key) or key + translated_key = self._symbol_resolver.get_translation(key) + # Only use the translated key when it is a string. get_translation() + # can return a list when a CommaDelimitedList parameter shares its + # name with a Resource/Output logical ID; using that list as a dict + # key would raise TypeError: unhashable type: 'list'. + processed_key = translated_key if isinstance(translated_key, str) else key try: processed_resource = self.intrinsic_property_resolver(val, ignore_errors, parent_function=processed_key) processed_dict[processed_key] = processed_resource diff --git a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py index 39d342764de..9390e3a2a64 100644 --- a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py +++ b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py @@ -1077,6 +1077,77 @@ def test_output_resolved(self): resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver) self.assertEqual(resolver.resolve_template(), expected_template) + def test_output_name_collides_with_comma_delimited_list_parameter(self): + # Regression test for https://github.com/aws/aws-sam-cli/issues/8627 + # When an Output's logical name matches the name of a CommaDelimitedList + # parameter, get_translation() returns a list for that name. Previously + # this list was used as a dict key in resolve_attribute() and raised + # TypeError: unhashable type: 'list'. The processed template must + # preserve the original Output key. + template = { + "Parameters": {"AppName": {"Type": "CommaDelimitedList", "Default": "hello, world"}}, + "Resources": { + "MyFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"Runtime": "python3.12", "Handler": "index.handler"}, + } + }, + "Outputs": {"AppName": {"Value": {"Ref": "AppName"}}}, + } + + # Real call sites (e.g. SamBaseProvider) pass resolved parameter + # values into logical_id_translator. Mirror that here so + # get_translation() actually returns the list form that triggers the + # crash on the unfixed code path. + symbol_resolver = IntrinsicsSymbolTable( + template=template, logical_id_translator={"AppName": "hello, world"} + ) + resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver) + processed_template = resolver.resolve_template() + + self.assertIn("AppName", processed_template["Outputs"]) + self.assertEqual(processed_template["Outputs"]["AppName"], {"Value": ["hello", "world"]}) + + def test_resource_name_collides_with_comma_delimited_list_parameter(self): + # Same root cause as the Output collision above, but exercised against + # the Resources branch of resolve_attribute to ensure both call sites + # are protected from the unhashable-key crash. + template = { + "Parameters": {"VpcSubnetIds": {"Type": "CommaDelimitedList", "Default": "subnet-a, subnet-b"}}, + "Resources": { + "VpcSubnetIds": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Type": "StringList", "Value": {"Ref": "VpcSubnetIds"}}, + } + }, + } + + symbol_resolver = IntrinsicsSymbolTable( + template=template, logical_id_translator={"VpcSubnetIds": "subnet-a, subnet-b"} + ) + resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver) + processed_template = resolver.resolve_template() + + self.assertIn("VpcSubnetIds", processed_template["Resources"]) + + def test_resource_logical_id_renaming_still_works(self): + # Guard against regressing the legitimate logical-ID renaming that + # resolve_attribute relies on. When logical_id_translator maps a + # resource key to a string, that string should still become the new + # key in the processed template. + template = { + "Resources": { + "OldName": {"Type": "AWS::ApiGateway::RestApi", "Properties": {"Name": "api"}}, + } + } + + symbol_resolver = IntrinsicsSymbolTable(template=template, logical_id_translator={"OldName": "NewName"}) + resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver) + processed_template = resolver.resolve_template() + + self.assertIn("NewName", processed_template["Resources"]) + self.assertNotIn("OldName", processed_template["Resources"]) + def load_test_data(self, template_path): integration_path = str(Path(__file__).resolve().parents[0].joinpath("test_data", template_path)) with open(integration_path) as f: From 92e0c791b51c36c8249dcaf1e4d99c735ecb9c70 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Wed, 13 May 2026 09:11:06 -0700 Subject: [PATCH 2/2] fix: apply black formatting to new regression test --- tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py index 9390e3a2a64..595b131d9da 100644 --- a/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py +++ b/tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py @@ -1099,9 +1099,7 @@ def test_output_name_collides_with_comma_delimited_list_parameter(self): # values into logical_id_translator. Mirror that here so # get_translation() actually returns the list form that triggers the # crash on the unfixed code path. - symbol_resolver = IntrinsicsSymbolTable( - template=template, logical_id_translator={"AppName": "hello, world"} - ) + symbol_resolver = IntrinsicsSymbolTable(template=template, logical_id_translator={"AppName": "hello, world"}) resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver) processed_template = resolver.resolve_template()