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: 14 additions & 1 deletion aws_lambda_builders/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,26 @@ def __init__(self, source_dir, dest_dir):
self._dest_dir = dest_dir

def execute(self):
# Match CopySourceAction, which this replaces for layers: a dependencies directory that was
# never created (download_dependencies=False against a missing cache) is skipped, not fatal.
if not os.path.isdir(self._source_dir):
LOG.warning("Skipping link operation since source %s does not exist", self._source_dir)
return

source_files = set(os.listdir(self._source_dir))

for source_file in source_files:
source_path = Path(self._source_dir, source_file)
destination_path = Path(self._dest_dir, source_file)
if destination_path.exists():
if destination_path.is_symlink() or destination_path.is_file():

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] os.remove() cannot delete a directory symlink on Windows — DeleteFileW fails with ERROR_ACCESS_DENIED for a reparse point that is a directory, which surfaces as PermissionError. Since is_symlink() is now the first branch, every rebuild of an already-linked dependency directory takes it, so the idempotent second build that test_is_idempotent covers on POSIX will fail on Windows for package directories (requests/, certifi/, …) whenever symlink creation succeeded on the first build (developer mode / elevated shell). The exception is not an ActionFailedError, so it propagates as a hard workflow failure rather than degrading to a copy.

The directory symlink must be removed with os.rmdir() on Windows (it unlinks the link, it does not recurse):

if destination_path.is_symlink():
   if sys.platform == "win32" and destination_path.is_dir():
       # os.remove() cannot delete a directory symlink/junction on Windows
       os.rmdir(destination_path)
   else:
       os.remove(destination_path)
elif destination_path.is_file():
   os.remove(destination_path)
elif destination_path.is_dir():
   shutil.rmtree(destination_path)
else:
   os.makedirs(destination_path.parent, exist_ok=True)

Note this also pre-dates the PR (the old exists() branch called os.remove on the same input), but this PR is what makes the path reachable for Python layer builds, and it is the branch being rewritten here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked this against Windows rather than reasoning about it, and it does not reproduce: all four TestLinkSourceAction tests pass on windows-latest, including test_is_idempotent, which is exactly the scenario described here.

test_is_idempotent links somepkg (a real directory), then runs the action again. The second run takes the is_symlink() branch and calls os.remove() on a directory symlink. _assert_linked then asserts destination.is_symlink() is True for that entry, so a silent fallback to copying would fail the test too — the symlink really was created and really was removed.

From windows-latest / 3.10 / unit-functional on this head (job 108558335756):

tests/unit/test_actions.py::TestLinkSourceAction::test_is_idempotent PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_links_into_an_empty_destination PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_replaces_a_dangling_symlink PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_replaces_a_real_directory_left_by_an_earlier_copying_build PASSED

Same result on the 3.11, 3.12 and 3.13 unit-functional jobs, so it is not a single-version quirk.

The Win32 distinction you cite is real — DeleteFile's own docs say "To remove an empty directory, use the RemoveDirectory function" — but os.remove is not a thin wrapper over DeleteFileW, and empirically CPython handles the directory reparse point on all four supported versions. Adding a sys.platform == "win32" branch would therefore be an untested path guarding a condition that does not occur, so I would rather not carry it.

Happy to reconsider with a failing case on a Windows configuration the CI matrix does not cover.

Your other two comments were both real and are fixed — replies on those separately.

# is_symlink() is checked first and deliberately: a dangling symlink is not
# exists(), so the previous exists() check left it in place and os.symlink then
# failed with FileExistsError.
os.remove(destination_path)
elif destination_path.is_dir():
# A real directory left behind by an earlier copying build. os.remove cannot remove
# it, and leaving it would shadow the symlink we are about to create.
shutil.rmtree(destination_path)
else:
os.makedirs(destination_path.parent, exist_ok=True)
utils.create_symlink_or_copy(str(source_path), str(destination_path))
Expand Down
46 changes: 45 additions & 1 deletion aws_lambda_builders/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@
LOG = logging.getLogger(__name__)


def _materialize_symlinked_destination(destination: str) -> None:
"""
Replace a symlinked destination with a real copy of what it points at.

A linking build leaves symlinks into the shared dependencies directory. Copying into one would
follow the link and write outside the destination tree, mutating a cache that later builds
reuse. Materialising it first keeps the merge where the caller asked for it, which is what a
copying build did.
"""
if not os.path.islink(destination):
return

LOG.debug("Replacing symlinked destination %s with a real copy before copying into it", destination)
link_target = os.path.realpath(destination)
os.unlink(destination)

if os.path.isdir(link_target):
copytree(link_target, destination)
elif os.path.isfile(link_target):
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy2(link_target, destination)
# A dangling link leaves nothing to preserve; the caller creates the destination itself.


def copytree(
source: str,
destination: str,
Expand Down Expand Up @@ -48,6 +72,8 @@ def copytree(
LOG.warning("Skipping copy operation since source %s does not exist", source)
return

_materialize_symlinked_destination(destination)

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 write-through fix only covers directory entries; file entries still write into the shared dependencies cache.

_materialize_symlinked_destination is called once per copytree invocation, and copytree only recurses (and therefore only re-checks the destination) when the source entry is a directory. The leaf branch is unchanged:

elif os.path.isdir(new_source):
    copytree(new_source, new_destination, ...)   # materialize runs on new_destination
else:
    shutil.copy2(new_source, new_destination)    # follows a symlinked new_destination

So for the same layer scenario you reproduced, but with a top-level module instead of a package, CopySourceAction(source_dir, artifacts_dir) still leaks: artifacts/six.py is a symlink into deps/six.py after LinkSourceAction, the user's own six.py hits the else branch, and shutil.copy2 opens the destination for writing, follows the link, and overwrites the file inside the shared deps directory that later builds reuse. A dangling destination symlink is worse — copy2 creates the file at the link target, outside the artifacts tree entirely.

Top-level files are exactly the case this PR added elsewhere (create_symlink_or_copy's six.py fallback, and the six.py fixture in TestLinkSourceAction), so a Python dependencies directory reliably produces symlinked file destinations.

Unlinking is sufficient here — copy2 overwrites the whole file, so there is nothing to preserve:

else:
    if os.path.islink(new_destination):
        os.unlink(new_destination)
    LOG.debug("Copying source file (%s) to destination (%s)", new_source, new_destination)
    shutil.copy2(new_source, new_destination)

Worth extending test_does_not_write_through_a_symlinked_destination with a symlinked file entry, since that test currently only exercises the directory path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in 900fe23. Reproduced against bbc105f: after LinkSourceAction, artifacts/six.py is a symlink into deps/six.py, and CopySourceAction with a user six.py left deps/six.py containing the user's code.

The fix is what you suggested: the file branch of copytree unlinks a symlinked new_destination before shutil.copy2. This also covers the dangling-link case, so no file is created at the link target.

Two new tests, test_does_not_write_through_a_symlinked_file_destination and test_does_not_create_a_file_through_a_dangling_symlink. Removing the unlink makes both fail.


if not os.path.exists(destination):
LOG.debug("Creating target folders at %s", destination)
os.makedirs(destination)
Expand Down Expand Up @@ -86,6 +112,10 @@ def copytree(
elif os.path.isdir(new_source):
copytree(new_source, new_destination, ignore=ignore, include=include, maintain_symlinks=maintain_symlinks)
else:
# copy2 opens the destination for writing and would follow a symlink into the shared
# dependencies directory; it replaces the whole file, so unlinking loses nothing.
if os.path.islink(new_destination):
os.unlink(new_destination)
LOG.debug("Copying source file (%s) to destination (%s)", new_source, new_destination)
shutil.copy2(new_source, new_destination)

Expand Down Expand Up @@ -210,7 +240,21 @@ def create_symlink_or_copy(source: str, destination: str) -> None:
"consider enabling the necessary settings or privileges on your system to support symbolic links.",
exc_info=ex if LOG.isEnabledFor(logging.DEBUG) else None,
)
copytree(source, destination)
if os.path.islink(destination):
# A leftover link is one reason os.symlink raised: the guard above misses a dangling one,
# which is not exists(). Copying through it would write outside the destination tree.
LOG.debug("Removing existing symlink at destination %s before copying", destination)
os.unlink(destination)
# A dependencies directory holds top-level files as well as packages (six.py, *.pth), and
# copytree assumes its source is a directory -- it would makedirs a folder named six.py and
# then raise NotADirectoryError on listdir.
if os.path.isdir(source):
copytree(source, destination)
elif os.path.isfile(source):
os.makedirs(os.path.dirname(destination), exist_ok=True)

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 new copy fallback does not account for destination already existing, and a dangling symlink there is precisely what sends it down this path.

os.symlink raises FileExistsError (a subclass of OSError) when the destination path is occupied — including by a broken link, since the guard above it (Path(destination).exists() and Path(destination).is_symlink()) is False for a dangling link. So control reaches the except OSError handler with the stale link still in place, and both new branches misbehave:

  • os.path.isdir(source) → copytree(source, destination) → os.path.exists(destination) is False for the dangling link → os.makedirs(destination) raises FileExistsError, which nothing catches. A hard build failure.
  • os.path.isfile(source) → shutil.copy2(source, destination) follows the link and creates the file at the link target, outside the destination tree, leaving destination a symlink. This is the same write-through that test_does_not_create_a_file_through_a_dangling_symlink now pins for copytree's leaf branch — the guard added there was not applied here.

It also misreports the cause: the operator sees "Symbolic link creation failed... consider enabling the necessary settings or privileges on your system" when the real problem is a leftover link, not a missing privilege.

LinkSourceAction is immune because this PR makes it unlink the destination first, but that fix lives in one caller while the shared helper stays broken for the others — LinkSinglePathAction (used by nodejs_npm and nodejs_npm_esbuild) passes a destination it has not removed, and so does copytree's maintain_symlinks branch via create_symlink_or_copy(linkto, new_destination). That branch has a related gap: when new_destination is a valid symlink into the dependencies directory, the early return keeps it and the source's own link is silently never created.

Clearing the destination in the handler fixes every caller at once and makes the LinkSourceAction workaround redundant:

except OSError as ex:
       LOG.warning(
           "Symbolic link creation failed, falling back to copying files instead. ...",
           exc_info=ex if LOG.isEnabledFor(logging.DEBUG) else None,
       )
       if os.path.islink(destination):
           # A leftover link at the destination is what made os.symlink raise. Copying through it
           # would write outside the destination tree, and makedirs() would fail on it outright.
           os.unlink(destination)
       if os.path.isdir(source):
           copytree(source, destination)
       elif os.path.isfile(source):
           ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partly confirmed. I reproduced all three sub-claims, and one of the two "misbehaves" cases doesn't hold.

The premise is right: for a dangling link Path(destination).exists() is False, so the guard is skipped, and os.symlink raises FileExistsError: [Errno 17]. Control does reach the handler with the stale link in place.

The file branch is a real bug. With a dangling destination and a file source, shutil.copy2 followed the link and wrote the dependency to the link target, leaving destination a symlink. I hit it through a real caller with no forced error at all — LinkSinglePathAction against a dangling destination wrote the file outside the artifact directory. Same write-through that the copytree leaf branch was fixed for; you're right that the guard wasn't applied here.

The directory branch does not fail. copytree starts with _materialize_symlinked_destination(destination), which this PR added, and os.path.islink is True for a dangling link, so the link is unlinked before os.makedirs ever runs. I ran it: no exception, destination ends up a real directory with the source's contents, and nothing is written to the link target.

Fixed in d0f5f6a with the handler-level unlink you proposed, since it covers LinkSinglePathAction and the maintain_symlinks branch too rather than just the one caller. New test test_fallback_does_not_copy_through_a_dangling_destination_link drives the real FileExistsError path (no mocked os.symlink) and asserts nothing lands at the link target; it fails if the unlink is disabled.

Two notes on the rest:

  • I kept the LinkSourceAction removal rather than treating it as redundant. It runs on the happy path, so the symlink succeeds; without it every stale entry would degrade to a full copy through this handler. It also clears stale real directories left by an earlier copying build, which the handler's islink check doesn't cover.
  • The last point — a valid symlink at new_destination makes the early return keep it, so the source's own link is never created — reproduces, but that guard is unchanged by this PR (it's the same on develop). I'd rather not change the existing skip-if-already-a-symlink contract inside a perf change for the python layer path; worth its own issue.

shutil.copy2(source, destination)
else:
LOG.warning("Skipping copy operation since source %s does not exist", source)


def _is_within_directory(directory: Union[str, os.PathLike], target: Union[str, os.PathLike]) -> bool:
Expand Down
15 changes: 13 additions & 2 deletions aws_lambda_builders/workflows/python_pip/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,19 @@ def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtim
# folder
if self.dependencies_dir and self.combine_dependencies:
# when copying downloaded dependencies back to artifacts folder, don't exclude anything
# symlinking python dependencies is disabled for now since it is breaking sam local commands
if False and is_experimental_build_improvements_enabled(self.experimental_flags):
#
# Symlinking is only safe for layers. A layer's artifacts are packed into a tarball
# (which dereferences symlinks) before they reach the local invoke container, whereas a
# function's artifacts are bind-mounted at /var/task, where a symlink pointing outside
# the mount dangles unless the caller passes `sam local invoke --mount-symlinks`. That
# option does not exist on `sam local start-api` / `start-lambda`, so linking function
# dependencies would break those commands -- which is why this was disabled wholesale in
# https://github.com/aws/aws-lambda-builders/pull/391. Keep copying for functions.
#
# The links are absolute, so they only resolve on the machine that built them. SAM CLI's
# container build (`sam build --use-container`) never sends a dependencies_dir over
# JSON-RPC, so it cannot reach this branch; a caller that does must share the path.
if self.is_building_layer and is_experimental_build_improvements_enabled(self.experimental_flags):

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] create_symlink_or_copy writes an absolute link target:

os.symlink(Path(source).absolute(), Path(destination).absolute())

Until now that was harmless for every LinkSourceAction consumer, because the only one (nodejs_npm_esbuild) links into self.scratch_dir, which is consumed by EsbuildBundleAction in the same process and then discarded. This PR is the first to leave absolute symlinks in a persisted artifacts_dir, so the build output is now only valid while dependencies_dir remains readable at that exact absolute path.

The case worth confirming before merge is sam build --use-container. There the workflow executes inside the container, so dependencies_dir is a container path and the links it writes point at container paths; once the artifacts are transferred back to the host those links have nothing to resolve to, and neither a dereferencing tar nor a zip can recover the contents. The PR description reasons carefully about sam local but does not mention the container build path.

If is_building_layer and dependencies_dir can be combined with a container build, this branch needs to exclude that mode as well. If they cannot, it is worth stating so in the comment next to the gate, since the comment currently enumerates the sam local reasoning only and a future reader has no signal that the container path was considered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked, and the container build can't reach this branch. LambdaBuildContainer in SAM CLI builds its JSON-RPC params without a dependencies_dir key (samcli/local/docker/lambda_build_container.py). In _build_layer, the container call _build_function_on_container(...) isn't passed one either, only _build_function_in_process is. So inside the container self.dependencies_dir is None, and the if self.dependencies_dir and self.combine_dependencies guard skips both link and copy.

Agreed that a future reader should see this. The comment next to the gate now says the links are absolute, that sam build --use-container never sends a dependencies_dir, and that any other caller has to share the path. This is in fadaa49.

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 safety argument for enabling this on layers covers one consumer of the layer artifacts directory but not the other. The comment reasons about the local invoke image (tarball, dereferenced) versus the function bind mount at /var/task — but artifacts_dir for a layer is also the input that sam package / sam deploy / sam sync zip and upload, and that path is not addressed here or in the PR description.

The reason it matters is that symlink handling differs by entry type. A symlinked top-level module such as six.py is yielded by os.walk in the files list and gets written into the archive with its content followed. A symlinked package directory such as requests is yielded in the dirs list, and os.walk does not descend into directory symlinks unless followlinks=True is passed. If the zipping code does not opt in, the failure mode is silent: the layer uploads with every package directory missing and only loose module files present, and the error surfaces at invoke time as ModuleNotFoundError rather than at build time.

Please confirm against the packaging code path (not just sam local) that a layer built with SAM_CLI_BETA_BUILD_PERFORMANCE deploys with complete dependencies, and record that in the validation notes the same way the local-invoke reasoning is recorded. If it does not hold, the gate needs to be narrower than is_building_layer.

self._actions.append(LinkSourceAction(self.dependencies_dir, artifacts_dir))
else:
self._actions.append(CopySourceAction(self.dependencies_dir, artifacts_dir))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/test_actions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import tempfile
from pathlib import Path
from unittest import TestCase
from unittest.mock import ANY, patch
Expand All @@ -13,6 +15,7 @@
CleanUpAction,
DependencyManager,
LinkSinglePathAction,
LinkSourceAction,
)


Expand Down Expand Up @@ -265,6 +268,65 @@ def _convert_strings_to_paths(source_dest_list):
return map(lambda item: (Path(item[0]), Path(item[1])), source_dest_list)


class TestLinkSourceAction(TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.source_dir = Path(self._tmp.name, "deps")
self.dest_dir = Path(self._tmp.name, "artifacts")
(self.source_dir / "somepkg").mkdir(parents=True)
(self.source_dir / "somepkg" / "__init__.py").write_text("hello")
(self.source_dir / "six.py").write_text("six")
self.dest_dir.mkdir()

def _execute(self):
LinkSourceAction(str(self.source_dir), str(self.dest_dir)).execute()

def _assert_linked(self):
for name in ("somepkg", "six.py"):
destination = self.dest_dir / name
self.assertTrue(destination.is_symlink(), f"{name} should be a symlink")
self.assertEqual(os.path.realpath(destination), str((self.source_dir / name).resolve()))
self.assertEqual((self.dest_dir / "somepkg" / "__init__.py").read_text(), "hello")

def test_links_into_an_empty_destination(self):
self._execute()
self._assert_linked()

def test_replaces_a_real_directory_left_by_an_earlier_copying_build(self):
# A build that copied dependencies leaves real directories behind. Without --clean they
# survive into the next build, and os.remove() cannot remove a directory.
stale = self.dest_dir / "somepkg"
stale.mkdir()
(stale / "__init__.py").write_text("stale")
(self.dest_dir / "six.py").write_text("stale")

self._execute()
self._assert_linked()

def test_replaces_a_dangling_symlink(self):
# A dangling symlink is not exists(), so it used to be left in place and os.symlink then
# raised FileExistsError, which create_symlink_or_copy swallowed into a full copy.
(self.dest_dir / "six.py").symlink_to(self._tmp.name + "/gone")
self.assertFalse((self.dest_dir / "six.py").exists())

self._execute()
self._assert_linked()

def test_is_idempotent(self):
self._execute()
self._execute()
self._assert_linked()

def test_skips_a_source_that_does_not_exist(self):
# CopySourceAction warns and continues here; the layer path must not turn that into a failure.
missing = Path(self._tmp.name, "never-created")

LinkSourceAction(str(missing), str(self.dest_dir)).execute()

self.assertEqual(os.listdir(self.dest_dir), [])


class TestLinkSinglePathAction(TestCase):
@patch("aws_lambda_builders.actions.os.makedirs")
@patch("aws_lambda_builders.utils.create_symlink_or_copy")
Expand Down
132 changes: 130 additions & 2 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import platform
import tempfile
from pathlib import Path

from unittest import TestCase
Expand Down Expand Up @@ -29,6 +31,9 @@ def test_must_create_symlink_with_absolute_path(self, patched_copy_tree, patched
@patch("aws_lambda_builders.utils.copytree")
def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched_path):
pathced_os.symlink.side_effect = OSError("Unable to create symlink")
# Without this the mocked Path makes the already-a-symlink branch truthy and the function
# returns before it ever calls os.symlink.
patched_path.return_value.exists.return_value = False

source_path = "source/path"
destination_path = "destination/path"
Expand All @@ -40,14 +45,137 @@ def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched
@patch("aws_lambda_builders.utils.Path")
@patch("aws_lambda_builders.utils.os")
@patch("aws_lambda_builders.utils.copytree")
def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched_path):
def test_must_not_copy_when_symlink_succeeds(self, patched_copy_tree, pathced_os, patched_path):

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] test_must_not_copy_when_symlink_succeeds does not exercise a successful os.symlink — it never reaches the call at all.

Because aws_lambda_builders.utils.Path is replaced by a MagicMock, both Path(destination).exists() and Path(destination).is_symlink() return truthy mocks, so create_symlink_or_copy takes the "already a symlink" early return:

if Path(destination).exists() and Path(destination).is_symlink():
   LOG.debug("Symlink between %s and %s already exists, skipping generating symlink", source, destination)
   return

Both assertions (symlink.assert_not_called(), copytree.assert_not_called()) then pass for a reason unrelated to the test name, and the test would keep passing if the symlink-success path regressed. This is exactly the pitfall the PR already fixes one method above, where patched_path.return_value.exists.return_value = False was added with a comment explaining it.

Either make the test assert the branch it actually reaches (and rename it accordingly, e.g. test_must_skip_when_destination_is_already_a_symlink, asserting copytree is not called), or force the guard false so a real os.symlink call is verified:

def test_must_not_copy_when_symlink_succeeds(self, patched_copy_tree, pathced_os, patched_path):
   patched_path.return_value.exists.return_value = False

   utils.create_symlink_or_copy("source/path", "destination/path")

   pathced_os.symlink.assert_called_once()
   patched_copy_tree.assert_not_called()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, the test never reached os.symlink. Replacing os.symlink with a bare return still passes it. Fixed in fadaa49 as you suggested: the test sets patched_path.return_value.exists.return_value = False and asserts symlink.assert_called_once(). With the same mutation, it now fails.

# As above: without this the already-a-symlink early return is taken and os.symlink is never reached.
patched_path.return_value.exists.return_value = False

source_path = "source/path"
destination_path = "destination/path"
utils.create_symlink_or_copy(source_path, destination_path)

pathced_os.symlink.assert_not_called()
pathced_os.symlink.assert_called_once()
patched_copy_tree.assert_not_called()

def test_falls_back_to_copying_a_top_level_file(self):
# A dependencies directory holds files as well as packages, and copytree cannot copy a file.
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp, "six.py")
source.write_text("body")
destination = Path(tmp, "artifacts", "six.py")

with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")):
utils.create_symlink_or_copy(str(source), str(destination))

self.assertTrue(destination.is_file())
self.assertEqual(destination.read_text(), "body")

def test_falls_back_to_copying_a_package_directory(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp, "somepkg")
source.mkdir()
(source / "__init__.py").write_text("body")
destination = Path(tmp, "artifacts", "somepkg")

with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")):
utils.create_symlink_or_copy(str(source), str(destination))

self.assertTrue(destination.is_dir())
self.assertEqual((destination / "__init__.py").read_text(), "body")

def test_fallback_skips_a_source_that_does_not_exist(self):
# maintain_symlinks passes raw os.readlink() output, which is often relative to the link
# rather than the CWD (npm's node_modules/.bin entries), so the fallback must skip it.
with tempfile.TemporaryDirectory() as tmp:
destination = Path(tmp, "artifacts", "tsc")

with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")):
utils.create_symlink_or_copy("../typescript/bin/tsc", str(destination))

self.assertFalse(destination.exists())

def test_fallback_does_not_copy_through_a_dangling_destination_link(self):
# A dangling link at the destination is not exists(), so the already-a-symlink guard misses
# it and os.symlink raises FileExistsError. The fallback must not then copy through it.
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp, "six.py")
source.write_text("body")
outside = Path(tmp, "outside.txt")
destination = Path(tmp, "artifacts", "six.py")
destination.parent.mkdir()
destination.symlink_to(str(outside))

utils.create_symlink_or_copy(str(source), str(destination))

self.assertFalse(outside.exists(), "copy followed a dangling link outside the destination tree")
self.assertFalse(destination.is_symlink())
self.assertEqual(destination.read_text(), "body")


class Test_copytree(TestCase):
def test_does_not_write_through_a_symlinked_destination(self):
"""A linking build leaves symlinks into the shared dependencies directory. Copying the
source tree over a colliding name must stay inside the destination tree rather than
following the link and mutating a cache that later builds reuse."""
with tempfile.TemporaryDirectory() as tmp:
deps = Path(tmp, "deps", "requests")
deps.mkdir(parents=True)
(deps / "__init__.py").write_text("dependency")

artifacts = Path(tmp, "artifacts")
artifacts.mkdir()
os.symlink(str(deps), str(artifacts / "requests"))

source = Path(tmp, "source", "requests")
source.mkdir(parents=True)
(source / "my_helper.py").write_text("user code")

utils.copytree(str(Path(tmp, "source")), str(artifacts))

self.assertFalse((deps / "my_helper.py").exists(), "source leaked into the dependencies directory")
self.assertFalse((artifacts / "requests").is_symlink())
self.assertEqual((artifacts / "requests" / "my_helper.py").read_text(), "user code")
self.assertEqual((artifacts / "requests" / "__init__.py").read_text(), "dependency")

def test_does_not_write_through_a_symlinked_file_destination(self):
with tempfile.TemporaryDirectory() as tmp:
deps = Path(tmp, "deps")
deps.mkdir()
(deps / "six.py").write_text("dependency")

artifacts = Path(tmp, "artifacts")
artifacts.mkdir()
os.symlink(str(deps / "six.py"), str(artifacts / "six.py"))

source = Path(tmp, "source")
source.mkdir()
(source / "six.py").write_text("user code")

utils.copytree(str(source), str(artifacts))

self.assertEqual(
(deps / "six.py").read_text(), "dependency", "source leaked into the dependencies directory"
)
self.assertFalse((artifacts / "six.py").is_symlink())
self.assertEqual((artifacts / "six.py").read_text(), "user code")

def test_does_not_create_a_file_through_a_dangling_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
outside = Path(tmp, "outside", "six.py")
outside.parent.mkdir()

artifacts = Path(tmp, "artifacts")
artifacts.mkdir()
os.symlink(str(outside), str(artifacts / "six.py"))

source = Path(tmp, "source")
source.mkdir()
(source / "six.py").write_text("user code")

utils.copytree(str(source), str(artifacts))

self.assertFalse(outside.exists(), "copy followed a dangling link outside the destination tree")
self.assertEqual((artifacts / "six.py").read_text(), "user code")


class TestDecode(TestCase):
def test_does_not_crash_non_utf8_encoding(self):
Expand Down
Loading
Loading