Skip to content

Commit 9a2a2bd

Browse files
Merge from aws/aws-sam-cli/develop
2 parents fe7543a + b7b0871 commit 9a2a2bd

7 files changed

Lines changed: 204 additions & 13 deletions

File tree

samcli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
SAM CLI version
33
"""
44

5-
__version__ = "1.156.0"
5+
__version__ = "1.157.0"

samcli/local/docker/lambda_image.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ def build(self, runtime, packagetype, image, layers, architecture, stream=None,
197197
tag_prefix = ""
198198

199199
if packagetype == IMAGE:
200-
base_image = image
200+
if self.invoke_images:
201+
base_image = self.invoke_images.get(function_name, self.invoke_images.get(None))
202+
if not base_image:
203+
base_image = image
201204
elif packagetype == ZIP:
202205
is_preview = runtime in TEST_RUNTIMES
203206
runtime_image_tag = Runtime.get_image_name_tag(runtime, architecture, is_preview=is_preview)

samcli/local/lambdafn/runtime.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ def _get_code_dir(self, code_path: str) -> str:
430430
"""
431431

432432
if code_path and os.path.isfile(code_path) and code_path.endswith(self.SUPPORTED_ARCHIVE_EXTENSIONS):
433-
decompressed_dir: str = _unzip_file(code_path)
433+
decompressed_dir: str = _unzip_file(code_path, mount_symlinks=self._mount_symlinks)
434434
self._temp_uncompressed_paths_to_be_cleaned += [decompressed_dir]
435435
return decompressed_dir
436436

@@ -709,11 +709,12 @@ def _on_code_change(self, functions):
709709
self._containers.pop(function_full_path, None)
710710

711711

712-
def _unzip_file(filepath):
712+
def _unzip_file(filepath, mount_symlinks=False):
713713
"""
714714
Helper method to unzip a file to a temporary directory
715715
716716
:param string filepath: Absolute path to this file
717+
:param bool mount_symlinks: If True, allow symlinks pointing outside extraction directory
717718
:return string: Path to the temporary directory where it was unzipped
718719
"""
719720

@@ -724,7 +725,7 @@ def _unzip_file(filepath):
724725

725726
LOG.info("Decompressing %s", filepath)
726727

727-
unzip(filepath, temp_dir)
728+
unzip(filepath, temp_dir, mount_symlinks=mount_symlinks)
728729

729730
# The directory that Python returns might have symlinks. The Docker File sharing settings will not resolve
730731
# symlinks. Hence get the real path before passing to Docker.

samcli/local/lambdafn/zip.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def _is_symlink(file_info):
3131
return (file_info.external_attr >> 28) == 0xA # noqa: PLR2004
3232

3333

34-
def _extract(file_info, output_dir, zip_ref):
34+
def _extract(file_info, output_dir, zip_ref, mount_symlinks=False):
3535
"""
3636
Unzip the given file into the given directory while preserving file permissions in the process.
3737
@@ -40,9 +40,12 @@ def _extract(file_info, output_dir, zip_ref):
4040
file_info : zipfile.ZipInfo
4141
The ZipInfo for a ZipFile
4242
output_dir : str
43-
Path to the directory where the it should be unzipped to
43+
Path to the directory where it should be unzipped to
4444
zip_ref : zipfile.ZipFile
4545
The ZipFile we are working with.
46+
mount_symlinks : bool
47+
If True, symlinks pointing outside the extraction directory are allowed.
48+
Default is False.
4649
4750
Returns
4851
-------
@@ -66,9 +69,31 @@ def _extract(file_info, output_dir, zip_ref):
6669
output_dir_abs = os.path.abspath(output_dir)
6770
link_name_abs = os.path.abspath(link_name)
6871

72+
# Validate that the symlink path itself is within the output directory
6973
if not link_name_abs.startswith(output_dir_abs + os.sep) and link_name_abs != output_dir_abs:
7074
raise ValueError(f"Failed to extract file from the zip file. The '{file_info.filename}' is invalid")
7175

76+
# When mount_symlinks is disabled (default)
77+
if not mount_symlinks:
78+
if os.path.isabs(source):
79+
LOG.warning("Use --mount-symlinks to allow symlinks pointing outside the extraction directory.")
80+
raise ValueError(
81+
"Failed to extract file from the zip file. " "A symlink has an absolute target which is not allowed"
82+
)
83+
84+
# For relative paths, validate that the resolved target stays within the extraction directory
85+
link_dir = os.path.dirname(link_name_abs)
86+
target_abs = os.path.abspath(os.path.join(link_dir, source))
87+
88+
if not target_abs.startswith(output_dir_abs + os.sep) and target_abs != output_dir_abs:
89+
LOG.warning(
90+
"Symlink pointing outside the extraction directory. "
91+
"Use --mount-symlinks to allow symlinks pointing outside the extraction directory."
92+
)
93+
raise ValueError(
94+
"Failed to extract file from the zip file. " "A symlink points outside the extraction directory"
95+
)
96+
7297
# make leading dirs if needed
7398
leading_dirs = os.path.dirname(link_name)
7499
if not os.path.exists(leading_dirs):
@@ -84,7 +109,7 @@ def _extract(file_info, output_dir, zip_ref):
84109
return link_name
85110

86111

87-
def unzip(zip_file_path, output_dir, permission=None):
112+
def unzip(zip_file_path, output_dir, permission=None, mount_symlinks=False):
88113
"""
89114
Unzip the given file into the given directory while preserving file permissions in the process.
90115
@@ -93,22 +118,27 @@ def unzip(zip_file_path, output_dir, permission=None):
93118
zip_file_path : str
94119
Path to the zip file
95120
output_dir : str
96-
Path to the directory where the it should be unzipped to
121+
Path to the directory where it should be unzipped to
97122
permission : int
98123
Permission to set in an octal int form
124+
mount_symlinks : bool
125+
If True, symlinks pointing outside the extraction directory are allowed.
126+
This corresponds to the --mount-symlinks CLI option. Default is False.
99127
"""
100128
extracted_path = None
101129
with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
102130
# For each item in the zip file, extract the file and set permissions if available
103131
for file_info in zip_ref.infolist():
104132
try:
105-
extracted_path = _extract(file_info, output_dir, zip_ref)
133+
extracted_path = _extract(file_info, output_dir, zip_ref, mount_symlinks)
106134

107135
# If the extracted_path is a symlink, do not set the permissions. If the target of the symlink does not
108136
# exist, then os.chmod will fail with FileNotFoundError
109137
if not os.path.islink(extracted_path):
110138
_set_permissions(file_info, extracted_path)
111139
_override_permissions(extracted_path, permission)
140+
except ValueError:
141+
raise
112142
except Exception as ex:
113143
LOG.debug("Failed to extract '%s' from %s: %s", file_info.filename, zip_file_path, ex)
114144

tests/unit/local/docker/test_lambda_image.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,79 @@ def test_building_image_with_custom_image_uri(self, build_image_patch, is_base_i
226226
)
227227
build_image_patch.assert_not_called()
228228

229+
@patch("samcli.local.docker.lambda_image.LambdaImage._build_image")
230+
def test_building_image_function_with_invoke_image_global(self, build_image_patch):
231+
docker_client_mock = Mock()
232+
layer_downloader_mock = Mock()
233+
setattr(layer_downloader_mock, "layer_cache", self.layer_cache_dir)
234+
docker_client_mock.images.get.return_value = Mock()
235+
236+
lambda_image = LambdaImage(
237+
layer_downloader_mock,
238+
False,
239+
False,
240+
docker_client=docker_client_mock,
241+
invoke_images={None: "my-custom-image:local"},
242+
)
243+
self.assertEqual(
244+
lambda_image.build(None, IMAGE, "mylambdaimage:v1", [], X86_64, function_name="Function1"),
245+
f"my-custom-image:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
246+
)
247+
build_image_patch.assert_called_once_with(
248+
"mylambdaimage:v1",
249+
f"my-custom-image:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
250+
[],
251+
X86_64,
252+
stream=ANY,
253+
)
254+
255+
@patch("samcli.local.docker.lambda_image.LambdaImage._build_image")
256+
def test_building_image_function_with_invoke_image_function_specific(self, build_image_patch):
257+
docker_client_mock = Mock()
258+
layer_downloader_mock = Mock()
259+
setattr(layer_downloader_mock, "layer_cache", self.layer_cache_dir)
260+
docker_client_mock.images.get.return_value = Mock()
261+
262+
lambda_image = LambdaImage(
263+
layer_downloader_mock,
264+
False,
265+
False,
266+
docker_client=docker_client_mock,
267+
invoke_images={
268+
None: "global-image:latest",
269+
"Function1": "function1-image:local",
270+
},
271+
)
272+
# Function-specific override
273+
self.assertEqual(
274+
lambda_image.build(None, IMAGE, "mylambdaimage:v1", [], X86_64, function_name="Function1"),
275+
f"function1-image:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
276+
)
277+
self.assertEqual(
278+
lambda_image.build(None, IMAGE, "mylambdaimage:v1", [], X86_64, function_name="Function2"),
279+
f"global-image:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
280+
)
281+
282+
@patch("samcli.local.docker.lambda_image.LambdaImage._build_image")
283+
def test_building_image_function_without_invoke_image_uses_template_image(self, build_image_patch):
284+
docker_client_mock = Mock()
285+
layer_downloader_mock = Mock()
286+
setattr(layer_downloader_mock, "layer_cache", self.layer_cache_dir)
287+
docker_client_mock.images.get.return_value = Mock()
288+
289+
lambda_image = LambdaImage(layer_downloader_mock, False, False, docker_client=docker_client_mock)
290+
self.assertEqual(
291+
lambda_image.build(None, IMAGE, "mylambdaimage:v1", [], X86_64, function_name="Function1"),
292+
f"mylambdaimage:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
293+
)
294+
build_image_patch.assert_called_once_with(
295+
"mylambdaimage:v1",
296+
f"mylambdaimage:{RAPID_IMAGE_TAG_PREFIX}-x86_64",
297+
[],
298+
X86_64,
299+
stream=ANY,
300+
)
301+
229302
@patch("samcli.local.docker.lambda_image.LambdaImage.is_base_image_current")
230303
@patch("samcli.local.docker.lambda_image.LambdaImage._build_image")
231304
@patch("samcli.local.docker.lambda_image.LambdaImage._generate_docker_image_version")

tests/unit/local/lambdafn/test_runtime.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ def test_must_uncompress_zip_files(self, extension, unzip_file_mock, shutil_mock
821821
result = self.runtime._get_code_dir(code_path)
822822
self.assertEqual(result, decompressed_dir)
823823

824-
unzip_file_mock.assert_called_with(code_path)
824+
unzip_file_mock.assert_called_with(code_path, mount_symlinks=False)
825825
os_mock.path.isfile.assert_called_with(code_path)
826826

827827
@patch("samcli.local.lambdafn.runtime.os")
@@ -1399,7 +1399,7 @@ def test_must_unzip_not_posix(self, os_mock, unzip_mock, tempfile_mock):
13991399
self.assertEqual(output, realpath)
14001400

14011401
tempfile_mock.mkdtemp.assert_called_with()
1402-
unzip_mock.assert_called_with(inputpath, tmpdir) # unzip files to temporary directory
1402+
unzip_mock.assert_called_with(inputpath, tmpdir, mount_symlinks=False) # unzip files to temporary directory
14031403
os_mock.path.realpath(tmpdir) # Return the real path of temporary directory
14041404
os_mock.chmod.assert_not_called() # Assert we do not chmod the temporary directory
14051405

@@ -1419,7 +1419,7 @@ def test_must_unzip_posix(self, os_mock, unzip_mock, tempfile_mock):
14191419
self.assertEqual(output, realpath)
14201420

14211421
tempfile_mock.mkdtemp.assert_called_with()
1422-
unzip_mock.assert_called_with(inputpath, tmpdir) # unzip files to temporary directory
1422+
unzip_mock.assert_called_with(inputpath, tmpdir, mount_symlinks=False) # unzip files to temporary directory
14231423
os_mock.path.realpath(tmpdir) # Return the real path of temporary directory
14241424
os_mock.chmod.assert_called_with(tmpdir, 0o755) # Assert we do chmod the temporary directory
14251425

tests/unit/local/lambdafn/test_zip.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,90 @@ def test_must_unzip(self, verify_external_attributes):
6565

6666
self._verify_file_count(verify_external_attributes)
6767

68+
def test_must_block_absolute_symlink_by_default(self):
69+
"""Test that absolute symlinks are blocked when mount_symlinks=False"""
70+
files_with_absolute_symlink = {
71+
"1.txt": {"file_type": 0o10, "contents": b"foo", "permissions": 0o644},
72+
"absolute_link": {"file_type": 0o12, "contents": b"/tmp/external", "permissions": 0o644},
73+
}
74+
75+
with self._create_zip(files_with_absolute_symlink) as zip_file_name:
76+
with self._temp_dir() as extract_dir:
77+
with self.assertRaises(ValueError) as context:
78+
unzip(zip_file_name, extract_dir, mount_symlinks=False)
79+
80+
self.assertIn("absolute target", str(context.exception).lower())
81+
82+
def test_must_block_relative_escape_symlink_by_default(self):
83+
"""Test that relative symlinks escaping the directory are blocked when mount_symlinks=False"""
84+
files_with_escape_symlink = {
85+
"1.txt": {"file_type": 0o10, "contents": b"foo", "permissions": 0o644},
86+
"escape_link": {"file_type": 0o12, "contents": b"../../external", "permissions": 0o644},
87+
}
88+
89+
with self._create_zip(files_with_escape_symlink) as zip_file_name:
90+
with self._temp_dir() as extract_dir:
91+
with self.assertRaises(ValueError) as context:
92+
unzip(zip_file_name, extract_dir, mount_symlinks=False)
93+
94+
self.assertIn("outside", str(context.exception).lower())
95+
96+
def test_must_allow_regular_symlink_by_default(self):
97+
"""Test that symlinks within the extraction directory are allowed by default"""
98+
with self._create_zip(self.files_with_external_attr) as zip_file_name:
99+
with self._temp_dir() as extract_dir:
100+
unzip(zip_file_name, extract_dir, mount_symlinks=False)
101+
102+
# Verify that symlink was created
103+
regular_path = os.path.join(extract_dir, "symlinkToF2")
104+
self.assertTrue(os.path.islink(regular_path))
105+
self.assertEqual(os.readlink(regular_path), "1.txt")
106+
107+
def test_must_block_subdirectory_symlink_escaping_extraction_dir(self):
108+
"""Test that a symlink nested in a subdirectory escaping via relative path is blocked"""
109+
files_with_nested_escape = {
110+
"1.txt": {"file_type": 0o10, "contents": b"foo", "permissions": 0o644},
111+
"subdir/link": {"file_type": 0o12, "contents": b"../../../external", "permissions": 0o644},
112+
}
113+
114+
with self._create_zip(files_with_nested_escape) as zip_file_name:
115+
with self._temp_dir() as extract_dir:
116+
with self.assertRaises(ValueError) as context:
117+
unzip(zip_file_name, extract_dir, mount_symlinks=False)
118+
119+
self.assertIn("outside", str(context.exception).lower())
120+
121+
def test_must_allow_relative_escape_symlink_with_mount_symlinks(self):
122+
"""Test that relative symlinks escaping the directory are allowed when mount_symlinks=True"""
123+
files_with_escape_symlink = {
124+
"1.txt": {"file_type": 0o10, "contents": b"foo", "permissions": 0o644},
125+
"escape_link": {"file_type": 0o12, "contents": b"../../external", "permissions": 0o644},
126+
}
127+
128+
with self._create_zip(files_with_escape_symlink) as zip_file_name:
129+
with self._temp_dir() as extract_dir:
130+
unzip(zip_file_name, extract_dir, mount_symlinks=True)
131+
132+
link_path = os.path.join(extract_dir, "escape_link")
133+
self.assertTrue(os.path.islink(link_path))
134+
self.assertEqual(os.readlink(link_path), "../../external")
135+
136+
def test_must_allow_absolute_symlink_with_mount_symlinks(self):
137+
"""Test that absolute symlinks are allowed when mount_symlinks=True"""
138+
files_with_absolute_symlink = {
139+
"1.txt": {"file_type": 0o10, "contents": b"foo", "permissions": 0o644},
140+
"external_link": {"file_type": 0o12, "contents": b"/tmp/external", "permissions": 0o644},
141+
}
142+
143+
with self._create_zip(files_with_absolute_symlink) as zip_file_name:
144+
with self._temp_dir() as extract_dir:
145+
unzip(zip_file_name, extract_dir, mount_symlinks=True)
146+
147+
# Verify the symlink was created
148+
link_path = os.path.join(extract_dir, "external_link")
149+
self.assertTrue(os.path.islink(link_path))
150+
self.assertEqual(os.readlink(link_path), "/tmp/external")
151+
68152
@contextmanager
69153
def _reset(self, verify_external_attributes):
70154
self.expected_files = 0

0 commit comments

Comments
 (0)