Skip to content

Commit 71358ed

Browse files
noirbizarresisp
authored andcommitted
feat(settings): allow to define some trusted repositories or prefixes
1 parent 0a9644d commit 71358ed

File tree

6 files changed

+89
-4
lines changed

6 files changed

+89
-4
lines changed

copier/main.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def _cleanup(self) -> None:
241241

242242
def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
243243
"""Check whether a template uses unsafe features."""
244-
if self.unsafe:
244+
if self.unsafe or self.settings.is_trusted(self.template.url):
245245
return
246246
features: set[str] = set()
247247
if self.template.jinja_extensions:

copier/settings.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import warnings
7+
from os.path import expanduser
78
from pathlib import Path
89
from typing import Any
910

@@ -19,7 +20,12 @@
1920
class Settings(BaseModel):
2021
"""User settings model."""
2122

22-
defaults: dict[str, Any] = Field(default_factory=dict)
23+
defaults: dict[str, Any] = Field(
24+
default_factory=dict, description="Default values for questions"
25+
)
26+
trust: set[str] = Field(
27+
default_factory=set, description="List of trusted repositories or prefixes"
28+
)
2329

2430
@classmethod
2531
def from_file(cls, settings_path: Path | None = None) -> Settings:
@@ -38,3 +44,18 @@ def from_file(cls, settings_path: Path | None = None) -> Settings:
3844
f"Settings file not found at {env_path}", MissingSettingsWarning
3945
)
4046
return cls()
47+
48+
def is_trusted(self, repository: str) -> bool:
49+
"""Check if a repository is trusted."""
50+
return any(
51+
repository.startswith(self.normalize(trusted))
52+
if trusted.endswith("/")
53+
else repository == self.normalize(trusted)
54+
for trusted in self.trust
55+
)
56+
57+
def normalize(self, url: str) -> str:
58+
"""Normalize an URL using user settings."""
59+
if url.startswith("~"): # Only expand on str to avoid messing with URLs
60+
url = expanduser(url)
61+
return url

docs/configuring.md

+4
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,10 @@ switch `--UNSAFE` or `--trust`.
15881588

15891589
Not supported in `copier.yml`.
15901590

1591+
!!! tip
1592+
1593+
See the [`trust` setting][trusted-locations] to mark some repositories as always trusted.
1594+
15911595
### `use_prereleases`
15921596

15931597
- Format: `bool`

docs/settings.md

+17
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,20 @@ to use the following well-known variables:
3737
| `user_email` | `str` | User's email address |
3838
| `github_user` | `str` | User's GitHub username |
3939
| `gitlab_user` | `str` | User's GitLab username |
40+
41+
## Trusted locations
42+
43+
Users may define trusted locations in the `trust` setting. It should be a list of Copier
44+
template repositories, or repositories prefix.
45+
46+
```yaml
47+
trust:
48+
- https://github.com/your_account/your_template.git
49+
- https://github.com/your_account/
50+
- ~/templates/
51+
```
52+
53+
!!! warning "Security considerations"
54+
55+
Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path.
56+
Locations not ending with `/` will be matched exactly.

tests/test_settings.py

+30
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def test_default_settings() -> None:
1212
settings = Settings()
1313

1414
assert settings.defaults == {}
15+
assert settings.trust == set()
1516

1617

1718
def test_settings_from_default_location(settings_path: Path) -> None:
@@ -69,3 +70,32 @@ def test_settings_defined_but_missing(
6970

7071
with pytest.warns(MissingSettingsWarning):
7172
Settings.from_file()
73+
74+
75+
@pytest.mark.parametrize(
76+
("repository", "trust", "is_trusted"),
77+
[
78+
("https://github.com/user/repo.git", set(), False),
79+
(
80+
"https://github.com/user/repo.git",
81+
{"https://github.com/user/repo.git"},
82+
True,
83+
),
84+
("https://github.com/user/repo", {"https://github.com/user/repo.git"}, False),
85+
("https://github.com/user/repo.git", {"https://github.com/user/"}, True),
86+
("https://github.com/user/repo.git", {"https://github.com/user/repo"}, False),
87+
("https://github.com/user/repo.git", {"https://github.com/user"}, False),
88+
("https://github.com/user/repo.git", {"https://github.com/"}, True),
89+
("https://github.com/user/repo.git", {"https://github.com"}, False),
90+
(f"{Path.home()}/template", set(), False),
91+
(f"{Path.home()}/template", {f"{Path.home()}/template"}, True),
92+
(f"{Path.home()}/template", {"~/template"}, True),
93+
(f"{Path.home()}/path/to/template", {"~/path/to/template"}, True),
94+
(f"{Path.home()}/path/to/template", {"~/path/to/"}, True),
95+
(f"{Path.home()}/path/to/template", {"~/path/to"}, False),
96+
],
97+
)
98+
def test_is_trusted(repository: str, trust: set[str], is_trusted: bool) -> None:
99+
settings = Settings(trust=trust)
100+
101+
assert settings.is_trusted(repository) == is_trusted

tests/test_unsafe.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from contextlib import nullcontext as does_not_raise
4+
from pathlib import Path
45
from typing import ContextManager
56

67
import pytest
@@ -90,20 +91,26 @@ def test_copy(
9091

9192

9293
@pytest.mark.parametrize("unsafe", [False, True])
94+
@pytest.mark.parametrize("trusted_from_settings", [False, True])
9395
def test_copy_cli(
9496
tmp_path_factory: pytest.TempPathFactory,
9597
capsys: pytest.CaptureFixture[str],
9698
unsafe: bool,
99+
trusted_from_settings: bool,
100+
settings_path: Path,
97101
) -> None:
98102
src, dst = map(tmp_path_factory.mktemp, ["src", "dst"])
99103
build_file_tree(
100104
{(src / "copier.yaml"): yaml.safe_dump({"_tasks": ["touch task.txt"]})}
101105
)
106+
if trusted_from_settings:
107+
settings_path.write_text(f"trust: ['{src}']")
108+
102109
_, retcode = CopierApp.run(
103110
["copier", "copy", *(["--UNSAFE"] if unsafe else []), str(src), str(dst)],
104111
exit=False,
105112
)
106-
if unsafe:
113+
if unsafe or trusted_from_settings:
107114
assert retcode == 0
108115
else:
109116
assert retcode == 4
@@ -322,10 +329,13 @@ def test_update(
322329

323330

324331
@pytest.mark.parametrize("unsafe", [False, "--trust", "--UNSAFE"])
332+
@pytest.mark.parametrize("trusted_from_settings", [False, True])
325333
def test_update_cli(
326334
tmp_path_factory: pytest.TempPathFactory,
327335
capsys: pytest.CaptureFixture[str],
328336
unsafe: bool | str,
337+
trusted_from_settings: bool,
338+
settings_path: Path,
329339
) -> None:
330340
src, dst = map(tmp_path_factory.mktemp, ["src", "dst"])
331341
unsafe_args = [unsafe] if unsafe else []
@@ -342,6 +352,9 @@ def test_update_cli(
342352
git("commit", "-m1")
343353
git("tag", "v1")
344354

355+
if trusted_from_settings:
356+
settings_path.write_text(f"trust: ['{src}']")
357+
345358
_, retcode = CopierApp.run(
346359
["copier", "copy", str(src), str(dst)] + unsafe_args,
347360
exit=False,
@@ -374,7 +387,7 @@ def test_update_cli(
374387
+ unsafe_args,
375388
exit=False,
376389
)
377-
if unsafe:
390+
if unsafe or trusted_from_settings:
378391
assert retcode == 0
379392
else:
380393
assert retcode == 4

0 commit comments

Comments
 (0)