Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions samtranslator/translator/transform.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
from functools import cache
from typing import Any

Expand All @@ -23,6 +24,20 @@ def transform(
:rtype: dict
"""

# Work on our own copies: the parser and plugins mutate the template in place --
# Globals are merged into resource properties and the Globals section itself is
# removed -- and to_py27_compatible_template() below mutates parameter_values in
# place, replacing its values with Py27UniStr/Py27Dict/Py27LongInt wrappers.
# Callers hand us objects they may still need afterwards, or may transform more
# than once.
#
# Both copies are taken before to_py27_compatible_template() so that only plain
# dicts and strings are copied. The Py27Dict/Py27UniStr wrappers that function
# installs carry hash-ordering state that logical ID generation depends on, and
# deep-copying them does not preserve it.
input_fragment = copy.deepcopy(input_fragment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The copy fixes the template, but the very next line still mutates the caller's other input. to_py27_compatible_template rewrites parameter_values in place (py27hash_fix.py):

if parameter_values:
   for key, val in parameter_values.items():
       parameter_values[key] = convertto_py27_type(val)

So after transform(template, params, loader) returns, the caller's params dict has had its values replaced with Py27UniStr/Py27Dict/Py27LongInt wrappers. This is caller-visible: Py27UniStr.__repr__ renders as u'value', and Py27Dict iterates in Python 2 hash order rather than insertion order, so a caller that logs or iterates its own parameter values after transforming sees different results than before the call.

Note that Translator.translate does not have this problem — SamParameterValues.__init__ already does copy.deepcopy(parameter_values) — so transform() is the only place the caller's dict is touched, and copying it at the same boundary is consistent with what the rest of the stack already does:

input_fragment = copy.deepcopy(input_fragment)
parameter_values = copy.deepcopy(parameter_values)

The new tests all pass {} as parameter_values, so this path is not covered; a case with a non-empty parameter value would catch it.

parameter_values = copy.deepcopy(parameter_values)

sam_parser = Parser()
to_py27_compatible_template(input_fragment, parameter_values)
translator = Translator(
Expand Down
147 changes: 147 additions & 0 deletions tests/translator/test_transform_does_not_mutate_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import copy
from unittest import TestCase
from unittest.mock import MagicMock, patch

from samtranslator.translator.transform import transform


def _managed_policy_loader():
loader = MagicMock()
loader.load.return_value = {
"AWSLambdaBasicExecutionRole": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
return loader


def _transform(template, parameter_values=None):
if parameter_values is None:
parameter_values = {}
with patch("boto3.session.Session.region_name", "us-east-1"):
return transform(template, parameter_values, _managed_policy_loader())


FUNCTION_PROPERTIES = {
"CodeUri": "s3://bucket/key",
"Handler": "index.handler",
"Runtime": "python3.11",
}

TEMPLATE_WITH_GLOBALS = {
"Transform": "AWS::Serverless-2016-10-31",
"Globals": {"Function": {"Timeout": 30}},
"Resources": {"Fn": {"Type": "AWS::Serverless::Function", "Properties": dict(FUNCTION_PROPERTIES)}},
}

TEMPLATE_WITH_API_EVENT = {
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"Fn": {
"Type": "AWS::Serverless::Function",
"Properties": {
**FUNCTION_PROPERTIES,
"Events": {"Api": {"Type": "Api", "Properties": {"Path": "/x", "Method": "get"}}},
},
}
},
}


TEMPLATE_WITH_EXPLICIT_API = {
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"Fn": {
"Type": "AWS::Serverless::Function",
"Properties": {
**FUNCTION_PROPERTIES,
"Events": {
"Api": {
"Type": "Api",
"Properties": {"Path": "/x", "Method": "get", "RestApiId": {"Ref": "Api"}},
}
},
},
},
"Api": {"Type": "AWS::Serverless::Api", "Properties": {"StageName": "prod"}},
},
}

TEMPLATE_WITH_EXPLICIT_HTTP_API = {
"Transform": "AWS::Serverless-2016-10-31",
"Resources": {
"Fn": {
"Type": "AWS::Serverless::Function",
"Properties": {
**FUNCTION_PROPERTIES,
"Events": {
"Http": {
"Type": "HttpApi",
"Properties": {"Path": "/x", "Method": "get", "ApiId": {"Ref": "Api"}},
}
},
},
},
"Api": {"Type": "AWS::Serverless::HttpApi", "Properties": {"StageName": "prod"}},
},
}


class TestTransformDoesNotMutateInput(TestCase):
def test_globals_template_is_not_modified(self):
template = copy.deepcopy(TEMPLATE_WITH_GLOBALS)
expected = copy.deepcopy(template)

_transform(template)

# The Globals section used to be deleted from the caller's template, and the
# merged Timeout written into the caller's resource properties.
self.assertEqual(template, expected)

def test_api_event_template_is_not_modified(self):
template = copy.deepcopy(TEMPLATE_WITH_API_EVENT)
expected = copy.deepcopy(template)

_transform(template)

self.assertEqual(template, expected)

def test_explicit_api_template_is_not_modified(self):
template = copy.deepcopy(TEMPLATE_WITH_EXPLICIT_API)
expected = copy.deepcopy(template)

_transform(template)

# The generated DefinitionBody used to be written into the caller's
# AWS::Serverless::Api resource.
self.assertEqual(template, expected)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] The template tests assert value equality only, which cannot detect the wrapper-type substitution that to_py27_compatible_template() performs on the caller's template. Py27Dict subclasses dict and Py27UniStr subclasses str without overriding eq, so assertEqual(template, expected) passes even when template["Resources"] has been replaced by a Py27Dict and its keys by Py27UniStr (py27hash_fix.py, to_py27_compatible_template).

This matters for the exact alternative implementation the PR description discusses and rejects: if the input_fragment copy is ever moved to after to_py27_compatible_template() (or into translate()), the caller's template still gets Resources/Parameters/API Properties swapped for Py27Dict — caller-visible via a different repr and Python 2 hash-order iteration — and this suite would not fail. test_parameter_values_are_not_modified already guards against precisely this with explicit type checks; the template tests lack the equivalent.

def test_explicit_api_template_is_not_modified(self):
       template = copy.deepcopy(TEMPLATE_WITH_EXPLICIT_API)
       expected = copy.deepcopy(template)

 transform(template)

       # The generated DefinitionBody used to be written into the caller's
       # AWS::Serverless::Api resource.
       self.assertEqual(template, expected)
       # Py27Dict/Py27UniStr compare equal to dict/str, so assert the types too.
       self.assertIs(type(template["Resources"]), dict)
       self.assertIs(type(template["Resources"]["Api"]["Properties"]), dict)


def test_parameter_values_are_not_modified(self):
# to_py27_compatible_template() used to replace parameter_values' entries
# in place with Py27UniStr/Py27Dict/Py27LongInt wrappers -- caller-visible
# via a different __repr__ and, for dicts, Python 2 hash-order iteration --
# even though it only runs for templates with an API resource.
template = copy.deepcopy(TEMPLATE_WITH_EXPLICIT_API)
parameter_values = {"StageName": "prod", "Count": 3, "Tags": {"a": 1, "b": 2}}
expected = copy.deepcopy(parameter_values)

_transform(template, parameter_values)

self.assertEqual(parameter_values, expected)
self.assertIs(type(parameter_values["StageName"]), str)
self.assertIs(type(parameter_values["Count"]), int)
self.assertIs(type(parameter_values["Tags"]), dict)

def test_transforming_the_same_template_twice_gives_the_same_result(self):
# Transforming the same object twice used to raise InvalidDocumentException:
# 'API method "get" defined multiple times for path "/x"', because the first
# transform left its own generated DefinitionBody in the caller's template.
for name, template in [
("rest api", TEMPLATE_WITH_EXPLICIT_API),
("http api", TEMPLATE_WITH_EXPLICIT_HTTP_API),
]:
with self.subTest(name):
reused = copy.deepcopy(template)

first = _transform(reused)
second = _transform(reused)

self.assertEqual(first, second)