diff --git a/aws_lambda_builders/workflows/dotnet_clipackage/actions.py b/aws_lambda_builders/workflows/dotnet_clipackage/actions.py
index 4a28770d6..456806c2a 100644
--- a/aws_lambda_builders/workflows/dotnet_clipackage/actions.py
+++ b/aws_lambda_builders/workflows/dotnet_clipackage/actions.py
@@ -108,7 +108,7 @@ def execute(self):
# The dotnet lambda package command outputs a zip file for the package. To make this compatible
# with the workflow, unzip the zip file into the artifacts directory and then delete the zip archive.
- self.os_utils.expand_zip(zipfullpath, self.artifacts_dir)
+ self.os_utils.unzip(zipfullpath, self.artifacts_dir)
except DotnetCLIExecutionError as ex:
raise ActionFailedError(str(ex))
diff --git a/aws_lambda_builders/workflows/dotnet_clipackage/utils.py b/aws_lambda_builders/workflows/dotnet_clipackage/utils.py
index 0df1a3fcd..cb629c777 100644
--- a/aws_lambda_builders/workflows/dotnet_clipackage/utils.py
+++ b/aws_lambda_builders/workflows/dotnet_clipackage/utils.py
@@ -1,14 +1,15 @@
"""
Commonly used utilities
"""
-
+import logging
import os
import platform
-import shutil
import subprocess
import zipfile
from aws_lambda_builders.utils import which
+LOG = logging.getLogger(__name__)
+
class OSUtils(object):
"""
@@ -25,11 +26,126 @@ def is_windows(self):
def which(self, executable, executable_search_paths=None):
return which(executable, executable_search_paths=executable_search_paths)
- def expand_zip(self, zipfullpath, destination_dir):
- ziparchive = zipfile.ZipFile(zipfullpath, "r")
- ziparchive.extractall(destination_dir)
- ziparchive.close()
- os.remove(zipfullpath)
+ def unzip(self, zip_file_path, output_dir, permission=None):
+ """
+ This method and dependent methods were copied from SAM CLI, but with the addition of deleting the zip file
+ https://github.com/aws/aws-sam-cli/blob/458076265651237a662a372f54d5b3df49fd6797/samcli/local/lambdafn/zip.py#L81
+
+ Unzip the given file into the given directory while preserving file permissions in the process.
+ Parameters
+ ----------
+ zip_file_path : str
+ Path to the zip file
+ output_dir : str
+ Path to the directory where the it should be unzipped to
+ permission : int
+ Permission to set in an octal int form
+ """
+
+ with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
+
+ # For each item in the zip file, extract the file and set permissions if available
+ for file_info in zip_ref.infolist():
+ extracted_path = self._extract(file_info, output_dir, zip_ref)
+
+ # If the extracted_path is a symlink, do not set the permissions. If the target of the symlink does not
+ # exist, then os.chmod will fail with FileNotFoundError
+ if not os.path.islink(extracted_path):
+ self._set_permissions(file_info, extracted_path)
+ self._override_permissions(extracted_path, permission)
+
+ if not os.path.islink(extracted_path):
+ self._override_permissions(output_dir, permission)
+
+ os.remove(zip_file_path)
+
+ def _is_symlink(self, file_info):
+ """
+ Check the upper 4 bits of the external attribute for a symlink.
+ See: https://unix.stackexchange.com/questions/14705/the-zip-formats-external-file-attribute
+ Parameters
+ ----------
+ file_info : zipfile.ZipInfo
+ The ZipInfo for a ZipFile
+ Returns
+ -------
+ bool
+ A response regarding whether the ZipInfo defines a symlink or not.
+ """
+
+ return (file_info.external_attr >> 28) == 0xA
+
+ def _extract(self, file_info, output_dir, zip_ref):
+ """
+ Unzip the given file into the given directory while preserving file permissions in the process.
+ Parameters
+ ----------
+ file_info : zipfile.ZipInfo
+ The ZipInfo for a ZipFile
+ output_dir : str
+ Path to the directory where the it should be unzipped to
+ zip_ref : zipfile.ZipFile
+ The ZipFile we are working with.
+ Returns
+ -------
+ string
+ Returns the target path the Zip Entry was extracted to.
+ """
+
+ # Handle any regular file/directory entries
+ if not self._is_symlink(file_info):
+ return zip_ref.extract(file_info, output_dir)
+
+ source = zip_ref.read(file_info.filename).decode("utf8")
+ link_name = os.path.normpath(os.path.join(output_dir, file_info.filename))
+
+ # make leading dirs if needed
+ leading_dirs = os.path.dirname(link_name)
+ if not os.path.exists(leading_dirs):
+ os.makedirs(leading_dirs)
+
+ # If the link already exists, delete it or symlink() fails
+ if os.path.lexists(link_name):
+ os.remove(link_name)
+
+ # Create a symbolic link pointing to source named link_name.
+ os.symlink(source, link_name)
+
+ return link_name
+
+ def _override_permissions(self, path, permission):
+ """
+ Forcefully override the permissions on the path
+ Parameters
+ ----------
+ path str
+ Path where the file or directory
+ permission octal int
+ Permission to set
+ """
+ if permission:
+ os.chmod(path, permission)
+
+ def _set_permissions(self, zip_file_info, extracted_path):
+ """
+ Sets permissions on the extracted file by reading the ``external_attr`` property of given file info.
+ Parameters
+ ----------
+ zip_file_info : zipfile.ZipInfo
+ Object containing information about a file within a zip archive
+ extracted_path : str
+ Path where the file has been extracted to
+ """
+
+ # Permission information is stored in first two bytes.
+ permission = zip_file_info.external_attr >> 16
+ if not permission:
+ # Zips created on certain Windows machines, however, might not have any permission information on them.
+ # Skip setting a permission on these files.
+ LOG.debug("File %s in zipfile does not have permission information", zip_file_info.filename)
+ return
+
+ os.chmod(extracted_path, permission)
@property
def pipe(self):
diff --git a/tests/integration/workflows/dotnet_clipackage/test_dotnet.py b/tests/integration/workflows/dotnet_clipackage/test_dotnet.py
index fce31b746..dfbf37103 100644
--- a/tests/integration/workflows/dotnet_clipackage/test_dotnet.py
+++ b/tests/integration/workflows/dotnet_clipackage/test_dotnet.py
@@ -45,6 +45,10 @@ def verify_architecture(self, deps_file_name, expected_architecture, version=Non
self.assertEqual(target, target_name)
+ def verify_execute_permissions(self, entrypoint_file_name):
+ entrypoint_file_path = os.path.join(self.artifacts_dir, entrypoint_file_name)
+ self.assertTrue(os.access(entrypoint_file_path, os.X_OK))
+
class TestDotnet31(TestDotnetBase):
"""
@@ -192,3 +196,29 @@ def test_with_defaults_file_arm64(self):
self.assertEqual(expected_files, output_files)
self.verify_architecture("WithDefaultsFile.deps.json", "linux-arm64", version="6.0")
+
+ def test_with_custom_runtime(self):
+ source_dir = os.path.join(self.TEST_DATA_FOLDER, "CustomRuntime6")
+
+ self.builder.build(
+ source_dir, self.artifacts_dir, self.scratch_dir, source_dir, runtime=self.runtime, architecture=X86_64
+ )
+
+ expected_files = {
+ "Amazon.Lambda.Core.dll",
+ "Amazon.Lambda.RuntimeSupport.dll",
+ "Amazon.Lambda.Serialization.SystemTextJson.dll",
+ "bootstrap",
+ "bootstrap.deps.json",
+ "bootstrap.dll",
+ "bootstrap.pdb",
+ "bootstrap.runtimeconfig.json",
+ }
+
+ output_files = set(os.listdir(self.artifacts_dir))
+
+ self.assertEqual(expected_files, output_files)
+ self.verify_architecture("bootstrap.deps.json", "linux-x64", version="6.0")
+ # Execute permissions are required for custom runtimes which bootstrap themselves, otherwise `sam local invoke`
+ # won't have permission to run the file
+ self.verify_execute_permissions("bootstrap")
diff --git a/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/CustomRuntime6.csproj b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/CustomRuntime6.csproj
new file mode 100644
index 000000000..1c10ba248
--- /dev/null
+++ b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/CustomRuntime6.csproj
@@ -0,0 +1,25 @@
+
+
+ Exe
+ net6.0
+ enable
+ enable
+ Lambda
+ bootstrap
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/Function.cs b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/Function.cs
new file mode 100644
index 000000000..0bbad05ac
--- /dev/null
+++ b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/Function.cs
@@ -0,0 +1,21 @@
+using Amazon.Lambda.Core;
+using Amazon.Lambda.RuntimeSupport;
+using Amazon.Lambda.Serialization.SystemTextJson;
+
+namespace CustomRuntime6;
+
+public class Function
+{
+ private static async Task Main(string[] args)
+ {
+ Func handler = FunctionHandler;
+ await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
+ .Build()
+ .RunAsync();
+ }
+
+ public static string FunctionHandler(string input, ILambdaContext context)
+ {
+ return input.ToUpper();
+ }
+}
\ No newline at end of file
diff --git a/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/aws-lambda-tools-defaults.json b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/aws-lambda-tools-defaults.json
new file mode 100644
index 000000000..9c44274ae
--- /dev/null
+++ b/tests/integration/workflows/dotnet_clipackage/testdata/CustomRuntime6/aws-lambda-tools-defaults.json
@@ -0,0 +1,16 @@
+{
+ "Information": [
+ "This file provides default values for the deployment wizard inside Visual Studio and the AWS Lambda commands added to the .NET Core CLI.",
+ "To learn more about the Lambda commands with the .NET Core CLI execute the following command at the command line in the project root directory.",
+ "dotnet lambda help",
+ "All the command line options for the Lambda command can be specified in this file."
+ ],
+ "profile": "",
+ "region": "",
+ "configuration": "Release",
+ "function-runtime": "provided.al2",
+ "function-memory-size": 256,
+ "function-timeout": 30,
+ "function-handler": "bootstrap",
+ "msbuild-parameters": "--self-contained true"
+}
\ No newline at end of file
diff --git a/tests/unit/workflows/dotnet_clipackage/test_utils.py b/tests/unit/workflows/dotnet_clipackage/test_utils.py
new file mode 100644
index 000000000..a02c89238
--- /dev/null
+++ b/tests/unit/workflows/dotnet_clipackage/test_utils.py
@@ -0,0 +1,34 @@
+import os
+import stat
+import tempfile
+from unittest import TestCase
+from zipfile import ZipFile
+
+from aws_lambda_builders.workflows.dotnet_clipackage.utils import OSUtils
+
+
+class TestDotnetCliPackageWorkflow(TestCase):
+ def test_unzip_keeps_execute_permission_on_linux(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with tempfile.TemporaryDirectory(dir=temp_dir) as output_dir:
+ test_file_name = "myFileToZip"
+ path_to_file_to_zip = os.path.join(temp_dir, test_file_name)
+ path_to_zip_file = os.path.join(temp_dir, "myZip.zip")
+ expected_output_file = os.path.join(output_dir, test_file_name)
+ with open(path_to_file_to_zip, "a") as the_file:
+ the_file.write("Hello World!")
+
+ # Set execute permissions on the file before zipping (won't do anything on Windows)
+ st = os.stat(path_to_file_to_zip)
+ os.chmod(path_to_file_to_zip, st.st_mode | stat.S_IEXEC | stat.S_IXOTH | stat.S_IXGRP)
+
+ # Zip the file
+ with ZipFile(path_to_zip_file, "w") as myzip:
+ myzip.write(path_to_file_to_zip, test_file_name)
+
+ # Unzip the file
+ OSUtils().unzip(path_to_zip_file, output_dir)
+ self.assertTrue(os.path.exists(expected_output_file))
+
+ # Assert that execute permissions are still applied
+ self.assertTrue(os.access(expected_output_file, os.X_OK))