Skip to content
68 changes: 67 additions & 1 deletion aws_lambda_builders/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,83 @@ 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))

self._prune_dangling_links(source_files)

def _prune_dangling_links(self, source_files):
"""
Remove links this action left behind for sources that have since disappeared.

The loop above only visits names present in the source directory, so a dependency dropped
from the manifest keeps its destination entry from the previous build. While that entry was
a copied directory it was merely stale; as a link into a dependencies directory that has
been cleaned it dangles, and a dangling link is not a directory -- os.walk yields it among
the *files*, where both zipfile.write and tarfile.add(dereference=True) raise
FileNotFoundError instead of skipping it.

Only broken links pointing back into our own source directory are removed, so a link that
another consumer put in the destination (nodejs_npm_esbuild links into a scratch_dir that
already holds copied source) is left alone.
"""
if not os.path.isdir(self._dest_dir):
return

for name in set(os.listdir(self._dest_dir)) - source_files:
stale_path = Path(self._dest_dir, name)
if not stale_path.is_symlink() or stale_path.exists():
continue
if not self._points_into_source(stale_path):
continue
LOG.debug("Removing %s: a link to a source that no longer exists", stale_path)
os.remove(stale_path)

def _points_into_source(self, link_path):
"""
Is this link one we created, i.e. does it name a direct child of our source directory?

Read with os.readlink rather than os.path.realpath. For a BROKEN link the two disagree by
platform: POSIX realpath expands the recorded target whether or not it exists, while
Windows falls back to _getfinalpathname_nonstrict, which resolves as much of the path as
exists and joins the rest -- for a dangling link that is the link's own path, so the
recorded target is never consulted and every comparison against the source directory
fails. Windows CI is the gate that catches a regression here.

samefile() does the comparison because it is immune to the ways two spellings of one
directory differ (8.3 short names such as RUNNER~1, case, a symlinked temp root).
"""
target = os.readlink(link_path)
if not os.path.isabs(target):
# A relative target is recorded verbatim and is meaningless without its link's parent.
target = os.path.join(os.path.dirname(link_path), target)

target_parent = os.path.dirname(os.path.normpath(target))
if not os.path.isdir(target_parent):
return False
return os.path.samefile(target_parent, self._source_dir)


class LinkSinglePathAction(BaseAction):
NAME = "LinkSource"
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
28 changes: 26 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,32 @@ 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.
#
# Parent packages are the one layer configuration that must keep copying.
# PythonCreateParentPackagesAction runs last and calls mkdir(parents=True) on
# artifacts_dir/<first segment>/..., which follows a link of that name straight into
# dependencies_dir and then renames the source files in there -- writing the build's
# own files into the cache that later --cached builds reuse. SAM CLI reaches this for a
# layer whose metadata carries BuildProperties.ParentPackageMode (app_builder.py's
# _get_build_options is called for layers too), so copying here is what keeps the
# dependencies directory read-only to this workflow.
if (
self.is_building_layer
and not self._should_create_parent_packages()
and is_experimental_build_improvements_enabled(self.experimental_flags)
):
self._actions.append(LinkSourceAction(self.dependencies_dir, artifacts_dir))
else:
self._actions.append(CopySourceAction(self.dependencies_dir, artifacts_dir))
Expand Down
152 changes: 152 additions & 0 deletions tests/unit/test_actions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import shutil
import tempfile
from pathlib import Path
from unittest import TestCase
from unittest.mock import ANY, patch
Expand All @@ -13,6 +16,7 @@
CleanUpAction,
DependencyManager,
LinkSinglePathAction,
LinkSourceAction,
)


Expand Down Expand Up @@ -265,6 +269,154 @@ 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), [])

def test_prunes_a_link_to_a_dependency_dropped_from_the_manifest(self):
# The link loop only visits names still in the source, so a dependency dropped from the
# manifest keeps its link from the previous build. It dangles once the dependencies dir is
# cleaned, and both zipfile.write and tarfile.add(dereference=True) raise FileNotFoundError
# on a broken link rather than skipping it.
self._execute()
shutil.rmtree(self.source_dir / "somepkg")
self.assertTrue((self.dest_dir / "somepkg").is_symlink())

self._execute()

self.assertNotIn("somepkg", os.listdir(self.dest_dir))
self.assertTrue((self.dest_dir / "six.py").is_symlink())
for root, _, files in os.walk(self.dest_dir, followlinks=True):
for name in files:
self.assertTrue(Path(root, name).exists(), f"{name} is a broken link")

def test_prunes_a_link_whose_recorded_target_is_relative(self):
# os.readlink returns a relative target verbatim, so it means nothing without the link's
# own parent. Without that join the containment check rejects the link and it survives.
self._execute()
(self.dest_dir / "somepkg").unlink()
os.symlink(os.path.join("..", "deps", "somepkg"), self.dest_dir / "somepkg")
shutil.rmtree(self.source_dir / "somepkg")
self.assertTrue((self.dest_dir / "somepkg").is_symlink())

self._execute()

self.assertNotIn("somepkg", os.listdir(self.dest_dir))

def test_keeps_a_broken_link_whose_target_tree_is_gone_entirely(self):
# samefile() raises FileNotFoundError when either side is missing, so a link into a tree
# that has been removed wholesale must be rejected before the comparison, not during it.
orphan = self.dest_dir / "orphan"
orphan.symlink_to(Path(self._tmp.name, "removed-tree", "pkg"))

self._execute()

self.assertTrue(orphan.is_symlink())
self._assert_linked()

def test_keeps_a_broken_link_pointing_outside_the_source_directory(self):
# nodejs_npm_esbuild links into a scratch_dir that already holds copied source. An entry
# this action did not create is not ours to reconcile, even when it is broken.
foreign = self.dest_dir / "foreign"
foreign.symlink_to(Path(self._tmp.name, "elsewhere"))

self._execute()

self.assertTrue(foreign.is_symlink())
self._assert_linked()

def test_leaves_a_real_stale_entry_from_a_dropped_dependency(self):
# Only broken links are ours to reconcile. A real directory left by an earlier copying
# build is stale but still packages and runs, which is how it behaved before this change;
# pruning build artifacts a build did not produce belongs to the caller, not this action.
stale = self.dest_dir / "dropped"
stale.mkdir()
(stale / "__init__.py").write_text("stale")

self._execute()

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

def test_keeps_a_working_link_into_the_source_tree_under_another_name(self):
# Only BROKEN links are reconciled. A link that still resolves is serving someone, even
# when it points into our source tree under a name the source directory does not list.
alias = self.dest_dir / "alias.py"
alias.symlink_to(self.source_dir / "somepkg" / "__init__.py")
self.assertTrue(alias.exists())

self._execute()

self.assertTrue(alias.is_symlink())
self.assertEqual(alias.read_text(), "hello")

def test_tolerates_an_empty_source_and_a_destination_that_was_never_created(self):
# With at least one source file the link loop creates the destination on the way past, so
# this is the only ordering that reaches the prune step with no destination directory: an
# empty dependencies dir (pip downloaded nothing) and a first build.
empty_source = Path(self._tmp.name, "empty-deps")
empty_source.mkdir()
dest = Path(self._tmp.name, "artifacts-2")

LinkSourceAction(str(empty_source), str(dest)).execute()

self.assertFalse(dest.exists())


class TestLinkSinglePathAction(TestCase):
@patch("aws_lambda_builders.actions.os.makedirs")
@patch("aws_lambda_builders.utils.create_symlink_or_copy")
Expand Down
Loading
Loading