diff --git a/src/poetry/config/config.py b/src/poetry/config/config.py index 03b5ee5bc63..1cd2ef6b673 100644 --- a/src/poetry/config/config.py +++ b/src/poetry/config/config.py @@ -29,7 +29,8 @@ from collections.abc import Mapping from collections.abc import Sequence - from poetry.config.config_source import ConfigSource +from poetry.config.config_source import ConfigSource +from poetry.config.config_source import split_config_key def boolean_validator(val: str) -> bool: @@ -313,7 +314,7 @@ def get(self, setting_name: str, default: Any = None) -> Any: """ Retrieve a setting value. """ - keys = setting_name.split(".") + keys = split_config_key(setting_name) build_config_settings: Mapping[ NormalizedName, Mapping[str, str | Sequence[str]] ] = {} diff --git a/src/poetry/config/config_source.py b/src/poetry/config/config_source.py index e06c934e70a..2722d28116c 100644 --- a/src/poetry/config/config_source.py +++ b/src/poetry/config/config_source.py @@ -33,6 +33,34 @@ def add_property(self, key: str, value: Any) -> None: ... def remove_property(self, key: str) -> None: ... +def split_config_key(key: str) -> list[str]: + parts: list[str] = [] + current: list[str] = [] + escaped = False + + for char in key: + if escaped: + current.append(char) + escaped = False + elif char == "\\": + escaped = True + elif char == ".": + parts.append("".join(current)) + current = [] + else: + current.append(char) + + if escaped: + current.append("\\") + + parts.append("".join(current)) + return parts + + +def escape_config_key(key: str) -> str: + return key.replace("\\", "\\\\").replace(".", "\\.") + + @dataclasses.dataclass class ConfigSourceMigration: old_key: str diff --git a/src/poetry/config/dict_config_source.py b/src/poetry/config/dict_config_source.py index 4b5a87a1699..e3e9a193796 100644 --- a/src/poetry/config/dict_config_source.py +++ b/src/poetry/config/dict_config_source.py @@ -4,6 +4,7 @@ from poetry.config.config_source import ConfigSource from poetry.config.config_source import PropertyNotFoundError +from poetry.config.config_source import split_config_key class DictConfigSource(ConfigSource): @@ -15,7 +16,7 @@ def config(self) -> dict[str, Any]: return self._config def get_property(self, key: str) -> Any: - keys = key.split(".") + keys = split_config_key(key) config = self._config for i, key in enumerate(keys): @@ -28,7 +29,7 @@ def get_property(self, key: str) -> Any: config = config[key] def add_property(self, key: str, value: Any) -> None: - keys = key.split(".") + keys = split_config_key(key) config = self._config for i, key in enumerate(keys): @@ -42,7 +43,7 @@ def add_property(self, key: str, value: Any) -> None: config = config[key] def remove_property(self, key: str) -> None: - keys = key.split(".") + keys = split_config_key(key) config = self._config for i, key in enumerate(keys): diff --git a/src/poetry/config/file_config_source.py b/src/poetry/config/file_config_source.py index 9170e0ef254..a2fbc71b03d 100644 --- a/src/poetry/config/file_config_source.py +++ b/src/poetry/config/file_config_source.py @@ -10,6 +10,7 @@ from poetry.config.config_source import ConfigSource from poetry.config.config_source import PropertyNotFoundError from poetry.config.config_source import drop_empty_config_category +from poetry.config.config_source import split_config_key if TYPE_CHECKING: @@ -33,7 +34,7 @@ def file(self) -> TOMLFile: return self._file def get_property(self, key: str) -> Any: - keys = key.split(".") + keys = split_config_key(key) config = self.file.read() if self.file.exists() else {} @@ -49,7 +50,7 @@ def get_property(self, key: str) -> Any: def add_property(self, key: str, value: Any) -> None: with self.secure() as toml: config: dict[str, Any] = toml - keys = key.split(".") + keys = split_config_key(key) for i, key in enumerate(keys): if key not in config and i < len(keys) - 1: @@ -64,7 +65,7 @@ def add_property(self, key: str, value: Any) -> None: def remove_property(self, key: str) -> None: with self.secure() as toml: config: dict[str, Any] = toml - keys = key.split(".") + keys = split_config_key(key) current_config = config for i, key in enumerate(keys): diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 60eaa33b046..a8778af4898 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -114,6 +114,7 @@ def handle(self) -> int: from poetry.core.pyproject.exceptions import PyProjectError from poetry.config.config import Config + from poetry.config.config_source import escape_config_key from poetry.config.file_config_source import FileConfigSource from poetry.locations import CONFIG_DIR from poetry.toml.file import TOMLFile @@ -182,7 +183,8 @@ def handle(self) -> int: if config.get("repositories") is not None: value = config.get("repositories") else: - repo = config.get(f"repositories.{m.group(1)}") + repository = escape_config_key(m.group(1)) + repo = config.get(f"repositories.{repository}") if repo is None: raise ValueError(f"There is no {m.group(1)} repository defined") @@ -221,20 +223,22 @@ def handle(self) -> int: if m: if not m.group(1): raise ValueError("You cannot remove the [repositories] section") + repository = escape_config_key(m.group(1)) if self.option("unset"): - repo = config.get(f"repositories.{m.group(1)}") - if repo is None: + try: + config.config_source.get_property(f"repositories.{repository}") + except PropertyNotFoundError: raise ValueError(f"There is no {m.group(1)} repository defined") - config.config_source.remove_property(f"repositories.{m.group(1)}") + config.config_source.remove_property(f"repositories.{repository}") return 0 if len(values) == 1: url = values[0] - config.config_source.add_property(f"repositories.{m.group(1)}.url", url) + config.config_source.add_property(f"repositories.{repository}.url", url) return 0 @@ -286,9 +290,9 @@ def handle(self) -> int: return 0 # handle certs - m = re.match(r"certificates\.([^.]+)\.(cert|client-cert)", self.argument("key")) + m = re.match(r"certificates\.(.+)\.(cert|client-cert)", self.argument("key")) if m: - repository = m.group(1) + repository = escape_config_key(m.group(1)) key = m.group(2) if self.option("unset"): diff --git a/src/poetry/publishing/publisher.py b/src/poetry/publishing/publisher.py index f752a0d5ee4..e6ed2a1fdd1 100644 --- a/src/poetry/publishing/publisher.py +++ b/src/poetry/publishing/publisher.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from poetry.config.config_source import escape_config_key from poetry.publishing.uploader import Uploader from poetry.utils.authenticator import Authenticator @@ -49,7 +50,8 @@ def publish( repository_name = "pypi" else: # Retrieving config information - url = self._poetry.config.get(f"repositories.{repository_name}.url") + repository = escape_config_key(repository_name) + url = self._poetry.config.get(f"repositories.{repository}.url") if url is None: raise RuntimeError(f"Repository {repository_name} is not defined") diff --git a/src/poetry/utils/authenticator.py b/src/poetry/utils/authenticator.py index 05b83a5400b..3832f3b80d9 100644 --- a/src/poetry/utils/authenticator.py +++ b/src/poetry/utils/authenticator.py @@ -22,6 +22,7 @@ from poetry.__version__ import __version__ from poetry.config.config import Config +from poetry.config.config_source import escape_config_key from poetry.console.exceptions import ConsoleMessage from poetry.console.exceptions import PoetryRuntimeError from poetry.exceptions import PoetryError @@ -50,12 +51,13 @@ def create( cls, repository: str, config: Config | None ) -> RepositoryCertificateConfig: config = config if config else Config.create() + repository_key = escape_config_key(repository) verify: str | bool = config.get( - f"certificates.{repository}.verify", - config.get(f"certificates.{repository}.cert", True), + f"certificates.{repository_key}.verify", + config.get(f"certificates.{repository_key}.cert", True), ) - client_cert: str = config.get(f"certificates.{repository}.client-cert") + client_cert: str = config.get(f"certificates.{repository_key}.client-cert") return cls( cert=Path(verify) if isinstance(verify, str) else None, @@ -389,7 +391,8 @@ def configured_repositories(self) -> dict[str, AuthenticatorRepositoryConfig]: if self._configured_repositories is None: self._configured_repositories = {} for repository_name in self._config.get("repositories", []): - url = self._config.get(f"repositories.{repository_name}.url") + repository = escape_config_key(repository_name) + url = self._config.get(f"repositories.{repository}.url") self._configured_repositories[repository_name] = ( AuthenticatorRepositoryConfig(repository_name, url) ) diff --git a/src/poetry/utils/password_manager.py b/src/poetry/utils/password_manager.py index e6455529bc3..1e1361741e1 100644 --- a/src/poetry/utils/password_manager.py +++ b/src/poetry/utils/password_manager.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from poetry.config.config import Config +from poetry.config.config_source import escape_config_key from poetry.utils.threading import atomic_cached_property @@ -210,10 +211,11 @@ def warn_plaintext_credentials_stored() -> None: logger.warning("Using a plaintext file to store credentials") def set_pypi_token(self, repo_name: str, token: str) -> None: + repository = escape_config_key(repo_name) if not self.use_keyring: self.warn_plaintext_credentials_stored() self._config.auth_config_source.add_property( - f"pypi-token.{repo_name}", token + f"pypi-token.{repository}", token ) else: self.keyring.set_password(repo_name, "__token__", token) @@ -228,7 +230,8 @@ def get_pypi_token(self, repo_name: str) -> str | None: :param repo_name: Name of repository. :return: Returns a token as a string if found, otherwise None. """ - token: str | None = self._config.get(f"pypi-token.{repo_name}") + repository = escape_config_key(repo_name) + token: str | None = self._config.get(f"pypi-token.{repository}") if token: return token @@ -240,14 +243,15 @@ def get_pypi_token(self, repo_name: str) -> str | None: def delete_pypi_token(self, repo_name: str) -> None: if not self.use_keyring: return self._config.auth_config_source.remove_property( - f"pypi-token.{repo_name}" + f"pypi-token.{escape_config_key(repo_name)}" ) self.keyring.delete_password(repo_name, "__token__") def get_http_auth(self, repo_name: str) -> HTTPAuthCredential: - username = self._config.get(f"http-basic.{repo_name}.username") - password = self._config.get(f"http-basic.{repo_name}.password") + repository = escape_config_key(repo_name) + username = self._config.get(f"http-basic.{repository}.username") + password = self._config.get(f"http-basic.{repository}.password") if password is None and self.use_keyring: password = self.keyring.get_password(repo_name, username) @@ -264,7 +268,8 @@ def set_http_password(self, repo_name: str, username: str, password: str) -> Non else: self.keyring.set_password(repo_name, username, password) - self._config.auth_config_source.add_property(f"http-basic.{repo_name}", auth) + repository = escape_config_key(repo_name) + self._config.auth_config_source.add_property(f"http-basic.{repository}", auth) def delete_http_password(self, repo_name: str) -> None: auth = self.get_http_auth(repo_name) @@ -275,7 +280,8 @@ def delete_http_password(self, repo_name: str) -> None: with suppress(PoetryKeyringError): self.keyring.delete_password(repo_name, auth.username) - self._config.auth_config_source.remove_property(f"http-basic.{repo_name}") + repository = escape_config_key(repo_name) + self._config.auth_config_source.remove_property(f"http-basic.{repository}") def get_credential( self, *names: str, username: str | None = None diff --git a/tests/config/test_dict_config_source.py b/tests/config/test_dict_config_source.py index 5c28589ce6a..48931038d55 100644 --- a/tests/config/test_dict_config_source.py +++ b/tests/config/test_dict_config_source.py @@ -66,3 +66,22 @@ def test_dict_config_source_get_property_should_raise_if_not_found() -> None: PropertyNotFoundError, match=r"Key virtualenvs\.use-poetry-python not in config" ): _ = config_source.get_property("virtualenvs.use-poetry-python") + + +def test_dict_config_source_escaped_dot_key() -> None: + config_source = DictConfigSource() + + config_source.add_property( + "repositories.foo\\.bar.url", "https://example.com/simple" + ) + assert config_source._config == { + "repositories": {"foo.bar": {"url": "https://example.com/simple"}} + } + + assert ( + config_source.get_property("repositories.foo\\.bar.url") + == "https://example.com/simple" + ) + + config_source.remove_property("repositories.foo\\.bar.url") + assert config_source._config == {"repositories": {"foo.bar": {}}} diff --git a/tests/config/test_file_config_source.py b/tests/config/test_file_config_source.py index 0e517d2e269..22ef3bc99a6 100644 --- a/tests/config/test_file_config_source.py +++ b/tests/config/test_file_config_source.py @@ -89,3 +89,21 @@ def test_file_config_source_get_property_should_raise_if_not_found( PropertyNotFoundError, match=r"Key virtualenvs\.use-poetry-python not in config" ): _ = config_source.get_property("virtualenvs.use-poetry-python") + + +def test_file_config_source_escaped_dot_key(tmp_path: Path) -> None: + config = tmp_path.joinpath("config.toml") + config.touch() + + config_source = FileConfigSource(TOMLFile(config)) + config_source.add_property( + "repositories.foo\\.bar.url", "https://example.com/simple" + ) + + assert config_source._file.read() == { + "repositories": {"foo.bar": {"url": "https://example.com/simple"}} + } + assert ( + config_source.get_property("repositories.foo\\.bar.url") + == "https://example.com/simple" + ) diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index a49360bb10c..31e394b965e 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -237,6 +237,13 @@ def test_display_single_setting( assert tester.io.fetch_output() == expected +def test_repositories_setting_with_dot_in_name(tester: CommandTester) -> None: + tester.execute("repositories.foo.bar https://bar.com/simple/") + tester.execute("repositories.foo.bar") + + assert tester.io.fetch_output() == "{'url': 'https://bar.com/simple/'}\n" + + def test_display_single_local_setting( command_tester_factory: CommandTesterFactory, fixture_dir: FixtureDirGetter ) -> None: diff --git a/tests/utils/test_password_manager.py b/tests/utils/test_password_manager.py index 329e3582ff1..d9d8042e13a 100644 --- a/tests/utils/test_password_manager.py +++ b/tests/utils/test_password_manager.py @@ -132,6 +132,66 @@ def test_set_http_password_with_unavailable_backend( assert auth["password"] == "baz" +def test_set_http_password_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + manager = PasswordManager(config) + manager.set_http_password("foo.bar", "baz", "qux") + + auth = config.get("http-basic.foo\\.bar") + assert auth["username"] == "baz" + assert auth["password"] == "qux" + + +def test_get_http_auth_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + config.auth_config_source.add_property( + "http-basic.foo\\.bar", {"username": "baz", "password": "qux"} + ) + manager = PasswordManager(config) + + auth = manager.get_http_auth("foo.bar") + assert auth.username == "baz" + assert auth.password == "qux" + + +def test_delete_http_password_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + config.auth_config_source.add_property( + "http-basic.foo\\.bar", {"username": "baz", "password": "qux"} + ) + manager = PasswordManager(config) + + manager.delete_http_password("foo.bar") + assert config.get("http-basic.foo\\.bar") is None + + +def test_get_http_auth_with_dot_in_repo_name_keyring( + config: Config, with_simple_keyring: None, dummy_keyring: DummyBackend +) -> None: + manager = PasswordManager(config) + manager.set_http_password("foo.bar", "baz", "qux") + + auth = manager.get_http_auth("foo.bar") + assert auth.username == "baz" + assert auth.password == "qux" + assert dummy_keyring.get_password("poetry-repository-foo.bar", "baz") == "qux" + + +def test_delete_http_password_with_dot_in_repo_name_keyring( + config: Config, with_simple_keyring: None, dummy_keyring: DummyBackend +) -> None: + manager = PasswordManager(config) + manager.set_http_password("foo.bar", "baz", "qux") + + manager.delete_http_password("foo.bar") + + assert dummy_keyring.get_password("poetry-repository-foo.bar", "baz") is None + assert config.get("http-basic.foo\\.bar") is None + + @pytest.mark.parametrize( ("username", "password", "is_valid"), [ @@ -189,6 +249,54 @@ def test_set_pypi_token_with_unavailable_backend( assert config.get("pypi-token.foo") == "baz" +def test_set_pypi_token_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + manager = PasswordManager(config) + manager.set_pypi_token("foo.bar", "baz") + + assert config.get("pypi-token.foo\\.bar") == "baz" + + +def test_get_pypi_token_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + config.auth_config_source.add_property("pypi-token.foo\\.bar", "baz") + manager = PasswordManager(config) + + assert manager.get_pypi_token("foo.bar") == "baz" + + +def test_delete_pypi_token_with_dot_in_repo_name( + config: Config, with_fail_keyring: None +) -> None: + config.auth_config_source.add_property("pypi-token.foo\\.bar", "baz") + manager = PasswordManager(config) + + manager.delete_pypi_token("foo.bar") + assert config.get("pypi-token.foo\\.bar") is None + + +def test_get_pypi_token_with_dot_in_repo_name_keyring( + config: Config, with_simple_keyring: None, dummy_keyring: DummyBackend +) -> None: + manager = PasswordManager(config) + manager.set_pypi_token("foo.bar", "baz") + + assert manager.get_pypi_token("foo.bar") == "baz" + assert dummy_keyring.get_password("poetry-repository-foo.bar", "__token__") == "baz" + + +def test_delete_pypi_token_with_dot_in_repo_name_keyring( + config: Config, with_simple_keyring: None, dummy_keyring: DummyBackend +) -> None: + manager = PasswordManager(config) + manager.set_pypi_token("foo.bar", "baz") + + manager.delete_pypi_token("foo.bar") + assert dummy_keyring.get_password("poetry-repository-foo.bar", "__token__") is None + + def test_get_pypi_token_with_unavailable_backend( config: Config, with_fail_keyring: None ) -> None: