diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80220cc84..f74b1e00d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: run: uv sync --frozen - name: Run pytest run: - uv run poe test --cov=./ --cov-report=xml -ra + uv run poe test --cov=./ --cov-report=xml -ra -s -k test_symlink_not_followed ${PYTEST_XDIST_MAXPROCESSES:+--maxprocesses=$PYTEST_XDIST_MAXPROCESSES} . env: PYTEST_XDIST_MAXPROCESSES: ${{ matrix.pytest-xdist-maxprocesses }} diff --git a/copier/_main.py b/copier/_main.py index fabbe2c2f..afe2c1207 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -723,6 +723,7 @@ def _render_template(self) -> None: Path(src_abspath).relative_to(self.template_copy_root) ) for dst_relpath, ctx in dst_relpaths_ctxs: + print(">>>", src_relpath, "->", dst_relpath, file=sys.stderr) dst_abspath = dst_root / dst_relpath if dst_abspath.is_symlink() and self.template.preserve_symlinks: # If destination path is a symlink, it can safely point outside the diff --git a/tests/test_symlinks.py b/tests/test_symlinks.py index 0113ed538..86cc22e2a 100644 --- a/tests/test_symlinks.py +++ b/tests/test_symlinks.py @@ -1,11 +1,12 @@ import os +import re from pathlib import Path import pytest from plumbum import local from copier import run_copy, run_update -from copier.errors import DirtyLocalWarning +from copier.errors import DirtyLocalWarning, ForbiddenPathError from .helpers import build_file_tree, git @@ -569,3 +570,40 @@ def test_symlinked_to_outside_destination_relative( assert (dst / "symlink_dir" / "outside.txt").read_text() == "outside" assert (dst / "a_symlink.txt").is_symlink() assert (dst / "a_symlink.txt").read_text() == "outside" + + +def test_symlink_not_followed(tmp_path_factory: pytest.TempPathFactory) -> None: + src, dst, other = map(tmp_path_factory.mktemp, ("src", "dst", "other")) + build_file_tree( + { + src / "copier.yaml": """\ + _preserve_symlinks: true + """, + # HACK: The `{% yield %}` tag is used to ensure the order in which the + # symlink and the generated file path are created, so the symlink is + # guaranteed to exist when the file is created, such that generating the + # `outside.txt` file might follow the symlink to its target location + # outside the destination root which is forbidden. + src + / "{% yield i from [1, 2] %}.{% endyield %}" + / "{% if i == 1 %}symlink{% endif %}": other, + src + / "{% yield i from [1, 2] %}.{% endyield %}" + / "{% if i == 2 %}{{ pathjoin('symlink', 'outside.txt') }}{% endif %}": "overwritten", + other / "outside.txt": "outside", + } + ) + + with local.cwd(src): + git("init") + git("add", "-A") + git("commit", "-m", "init") + + with pytest.raises( + ForbiddenPathError, + match=re.escape('"symlink/outside.txt" is forbidden'), + ): + run_copy(str(src), dst, defaults=True, overwrite=True) + + assert (dst / "symlink").is_symlink() + assert (dst / "symlink" / "outside.txt").read_text() == "outside"