|
9 | 9 | import subprocess |
10 | 10 | import sys |
11 | 11 | import warnings |
12 | | -from collections.abc import Iterable, Mapping, Sequence |
| 12 | +from collections.abc import Callable, Iterable, Mapping, Sequence |
13 | 13 | from contextlib import suppress |
14 | 14 | from contextvars import ContextVar |
15 | 15 | from dataclasses import field, replace |
|
22 | 22 | from types import TracebackType |
23 | 23 | from typing import ( |
24 | 24 | Any, |
25 | | - Callable, |
| 25 | + Final, |
26 | 26 | Literal, |
| 27 | + ParamSpec, |
27 | 28 | TypeVar, |
28 | 29 | get_args, |
29 | 30 | overload, |
|
32 | 33 |
|
33 | 34 | from jinja2.loaders import FileSystemLoader |
34 | 35 | from packaging.version import InvalidVersion, parse as parse_version |
35 | | -from pathspec import PathSpec |
| 36 | +from packaging.version import Version |
| 37 | +from pathspec import PathSpec, __version__ as pathspec_version |
36 | 38 | from plumbum import ProcessExecutionError, colors |
37 | 39 | from plumbum.machines import local |
38 | 40 | from pydantic import ConfigDict, PositiveInt |
|
60 | 62 | JSONSerializable, |
61 | 63 | LazyDict, |
62 | 64 | Operation, |
63 | | - ParamSpec, |
64 | 65 | Phase, |
65 | 66 | RelativePath, |
66 | 67 | StrOrPath, |
|
84 | 85 | _P = ParamSpec("_P") |
85 | 86 |
|
86 | 87 | _operation: ContextVar[Operation] = ContextVar("_operation") |
| 88 | +_pathspec_pattern: Final = ( |
| 89 | + "gitignore" if Version(pathspec_version) >= Version("1.0.0") else "gitwildmatch" |
| 90 | +) |
87 | 91 |
|
88 | 92 |
|
89 | 93 | def as_operation(value: Operation) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: |
@@ -574,7 +578,7 @@ def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]: |
574 | 578 | """Produce a function that matches against specified patterns.""" |
575 | 579 | # TODO Is normalization really needed? |
576 | 580 | normalized_patterns = (normalize("NFD", pattern) for pattern in patterns) |
577 | | - spec = PathSpec.from_lines("gitwildmatch", normalized_patterns) |
| 581 | + spec = PathSpec.from_lines(_pathspec_pattern, normalized_patterns) |
578 | 582 | return spec.match_file |
579 | 583 |
|
580 | 584 | def _solve_render_conflict(self, dst_relpath: Path) -> bool: |
@@ -850,15 +854,39 @@ def match_skip(self) -> Callable[[Path], bool]: |
850 | 854 | def _render_template(self) -> None: |
851 | 855 | """Render the template in the subproject root.""" |
852 | 856 | follow_symlinks = not self.template.preserve_symlinks |
853 | | - cwd = Path.cwd() |
| 857 | + dst_root = self.dst_path.resolve() |
854 | 858 | for src in scantree(str(self.template_copy_root), follow_symlinks): |
855 | 859 | src_abspath = Path(src.path) |
| 860 | + # If the source is a symlink, we are not preserving symlinks, and the |
| 861 | + # symlink target is outside the template root, this means that we are |
| 862 | + # copying a file/directory from outside the template, which is |
| 863 | + # forbidden, so raise an error. |
| 864 | + if ( |
| 865 | + src_abspath.is_symlink() |
| 866 | + and not self.template.preserve_symlinks |
| 867 | + and not (src_abspath.resolve()).is_relative_to( |
| 868 | + self.template.local_abspath |
| 869 | + ) |
| 870 | + ): |
| 871 | + raise ForbiddenPathError( |
| 872 | + path=src_abspath.relative_to(self.template_copy_root) |
| 873 | + ) |
856 | 874 | src_relpath = Path(src_abspath).relative_to(self.template.local_abspath) |
857 | 875 | dst_relpaths_ctxs = self._render_path( |
858 | 876 | Path(src_abspath).relative_to(self.template_copy_root) |
859 | 877 | ) |
860 | 878 | for dst_relpath, ctx in dst_relpaths_ctxs: |
861 | | - if not cwd.joinpath(dst_relpath).resolve().is_relative_to(cwd): |
| 879 | + dst_abspath = dst_root / dst_relpath |
| 880 | + if dst_abspath.is_symlink() and self.template.preserve_symlinks: |
| 881 | + # If destination path is a symlink, it can safely point outside the |
| 882 | + # subproject dir, while still itself existing within the subproject. |
| 883 | + # (So long as nothing is templated into it (if it is a directory), |
| 884 | + # which would be caught by that path's own check.) |
| 885 | + # Therefore avoid resolving the symlink itself: |
| 886 | + dst_realpath = dst_abspath.parent.resolve() / dst_abspath.name |
| 887 | + else: |
| 888 | + dst_realpath = dst_abspath.resolve() |
| 889 | + if not dst_realpath.is_relative_to(dst_root): |
862 | 890 | raise ForbiddenPathError(path=dst_relpath) |
863 | 891 | if self.match_exclude(dst_relpath): |
864 | 892 | continue |
@@ -1384,7 +1412,10 @@ def _apply_update(self) -> None: # noqa: C901 |
1384 | 1412 | data={ |
1385 | 1413 | k: v |
1386 | 1414 | for k, v in self.answers.combined.items() |
1387 | | - if k not in self.answers.hidden |
| 1415 | + if not k.startswith("_") |
| 1416 | + and k not in self.answers.hidden |
| 1417 | + and isinstance(k, JSONSerializable) |
| 1418 | + and isinstance(v, JSONSerializable) |
1388 | 1419 | }, |
1389 | 1420 | defaults=True, |
1390 | 1421 | quiet=True, |
@@ -1474,7 +1505,15 @@ def _apply_update(self) -> None: # noqa: C901 |
1474 | 1505 | # Remove ".rej" suffix |
1475 | 1506 | fname = fname[:-4] |
1476 | 1507 | # Undo possible non-rejected chunks |
1477 | | - git("checkout", "--", fname) |
| 1508 | + git( |
| 1509 | + # Ignore hooks to avoid errors from them or |
| 1510 | + # issues when .pre-commit-config.yaml is changed |
| 1511 | + "-c", |
| 1512 | + f"core.hooksPath={os.devnull}", |
| 1513 | + "checkout", |
| 1514 | + "--", |
| 1515 | + fname, |
| 1516 | + ) |
1478 | 1517 | # 3-way-merge the file directly |
1479 | 1518 | git( |
1480 | 1519 | "merge-file", |
|
0 commit comments