Skip to content

Commit

Permalink
feat(settings): allows to defined some trusted repositories or prefixes
Browse files Browse the repository at this point in the history
  • Loading branch information
noirbizarre committed Jan 20, 2025
1 parent 638f131 commit ac3b856
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 8 deletions.
8 changes: 3 additions & 5 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,12 @@ def __enter__(self) -> Worker:
return self

@overload
def __exit__(self, type: None, value: None, traceback: None) -> None:
...
def __exit__(self, type: None, value: None, traceback: None) -> None: ...

@overload
def __exit__(
self, type: type[BaseException], value: BaseException, traceback: TracebackType
) -> None:
...
) -> None: ...

def __exit__(
self,
Expand All @@ -244,7 +242,7 @@ def _cleanup(self) -> None:

def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
"""Check whether a template uses unsafe features."""
if self.unsafe:
if self.unsafe or self.settings.is_trusted(self.template.url):
return
features: set[str] = set()
if self.template.jinja_extensions:
Expand Down
24 changes: 23 additions & 1 deletion copier/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""User setttings models and helper functions."""

from __future__ import annotations

import os
from os.path import expanduser
from pathlib import Path
from typing import Any

Expand All @@ -15,7 +17,12 @@
class Settings(BaseModel):
"""User settings model."""

defaults: dict[str, Any] = Field(default_factory=dict)
defaults: dict[str, Any] = Field(
default_factory=dict, description="Default values for questions"
)
trust: set[str] = Field(
default_factory=set, description="List of trusted repositories or prefixes"
)

@classmethod
def from_file(cls, settings_path: Path | None = None) -> Settings:
Expand All @@ -29,3 +36,18 @@ def from_file(cls, settings_path: Path | None = None) -> Settings:
data = yaml.safe_load(settings_path.read_text())
return cls.model_validate(data)
return cls()

def is_trusted(self, repository: str) -> bool:
"""Check if a repository is trusted."""
return any(
repository.startswith(self.normalize(trusted))
if trusted.endswith("/")
else repository == self.normalize(trusted)
for trusted in self.trust
)

def normalize(self, url: str) -> str:
"""Normalize an URL using user settings."""
if url.startswith("~"): # Only expand on str to avoid messing with URLs
url = expanduser(url)
return url
4 changes: 4 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,10 @@ switch `--UNSAFE` or `--trust`.

Not supported in `copier.yml`.

!!! tip

See the [`trust` setting][trusted-locations] to mark some repositories as always trusted.

### `use_prereleases`

- Format: `bool`
Expand Down
17 changes: 17 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,20 @@ to use the following well-known variables:
| `user_email` | `str` | User's email address |
| `github_user` | `str` | User's GitHub username |
| `gitlab_user` | `str` | User's GitLab username |

## Trusted locations

Users may define trusted locations in the `trust` setting. It should be a list of Copier
template repositories, or repositories prefix.

```yaml
trust:
- https://github.com/your_account/your_template.git
- https://github.com/your_account/
- ~/templates/
```

!!! warning "Security considerations"

Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path.
Locations not ending with `/` will be matched exactly.
30 changes: 30 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def test_default_settings() -> None:
settings = Settings()

assert settings.defaults == {}
assert settings.trust == set()


def test_settings_from_default_location(settings_path: Path) -> None:
Expand Down Expand Up @@ -59,3 +60,32 @@ def test_settings_from_param(
settings = Settings.from_file(file_path)

assert settings.defaults == {"from": "file"}


@pytest.mark.parametrize(
("repository", "trust", "is_trusted"),
[
("https://github.com/user/repo.git", set(), False),
(
"https://github.com/user/repo.git",
{"https://github.com/user/repo.git"},
True,
),
("https://github.com/user/repo", {"https://github.com/user/repo.git"}, False),
("https://github.com/user/repo.git", {"https://github.com/user/"}, True),
("https://github.com/user/repo.git", {"https://github.com/user/repo"}, False),
("https://github.com/user/repo.git", {"https://github.com/user"}, False),
("https://github.com/user/repo.git", {"https://github.com/"}, True),
("https://github.com/user/repo.git", {"https://github.com"}, False),
(f"{Path.home()}/template", set(), False),
(f"{Path.home()}/template", {f"{Path.home()}/template"}, True),
(f"{Path.home()}/template", {"~/template"}, True),
(f"{Path.home()}/path/to/template", {"~/path/to/template"}, True),
(f"{Path.home()}/path/to/template", {"~/path/to/"}, True),
(f"{Path.home()}/path/to/template", {"~/path/to"}, False),
],
)
def test_is_trusted(repository: str, trust: set[str], is_trusted: bool) -> None:
settings = Settings(trust=trust)

assert settings.is_trusted(repository) == is_trusted
17 changes: 15 additions & 2 deletions tests/test_unsafe.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from contextlib import nullcontext as does_not_raise
from pathlib import Path
from typing import ContextManager

import pytest
Expand Down Expand Up @@ -90,20 +91,26 @@ def test_copy(


@pytest.mark.parametrize("unsafe", [False, True])
@pytest.mark.parametrize("trusted_from_settings", [False, True])
def test_copy_cli(
tmp_path_factory: pytest.TempPathFactory,
capsys: pytest.CaptureFixture[str],
unsafe: bool,
trusted_from_settings: bool,
settings_path: Path,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ["src", "dst"])
build_file_tree(
{(src / "copier.yaml"): yaml.safe_dump({"_tasks": ["touch task.txt"]})}
)
if trusted_from_settings:
settings_path.write_text(f"trust: ['{src}']")

_, retcode = CopierApp.run(
["copier", "copy", *(["--UNSAFE"] if unsafe else []), str(src), str(dst)],
exit=False,
)
if unsafe:
if unsafe or trusted_from_settings:
assert retcode == 0
else:
assert retcode == 4
Expand Down Expand Up @@ -322,10 +329,13 @@ def test_update(


@pytest.mark.parametrize("unsafe", [False, "--trust", "--UNSAFE"])
@pytest.mark.parametrize("trusted_from_settings", [False, True])
def test_update_cli(
tmp_path_factory: pytest.TempPathFactory,
capsys: pytest.CaptureFixture[str],
unsafe: bool | str,
trusted_from_settings: bool,
settings_path: Path,
) -> None:
src, dst = map(tmp_path_factory.mktemp, ["src", "dst"])
unsafe_args = [unsafe] if unsafe else []
Expand All @@ -342,6 +352,9 @@ def test_update_cli(
git("commit", "-m1")
git("tag", "v1")

if trusted_from_settings:
settings_path.write_text(f"trust: ['{src}']")

_, retcode = CopierApp.run(
["copier", "copy", str(src), str(dst)] + unsafe_args,
exit=False,
Expand Down Expand Up @@ -374,7 +387,7 @@ def test_update_cli(
+ unsafe_args,
exit=False,
)
if unsafe:
if unsafe or trusted_from_settings:
assert retcode == 0
else:
assert retcode == 4
Expand Down

0 comments on commit ac3b856

Please sign in to comment.