From b577d70543d9129ddf70ed03aaa627dfc4874819 Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Sun, 25 Aug 2019 11:48:19 +0100 Subject: [PATCH 1/6] Add custom certificate authority and client certificate support --- poetry/console/commands/config.py | 21 +++++++ poetry/installation/pip_installer.py | 6 ++ poetry/masonry/publishing/publisher.py | 12 ++-- poetry/masonry/publishing/uploader.py | 13 ++++- poetry/poetry.py | 10 +++- poetry/repositories/legacy_repository.py | 26 ++++++++- poetry/utils/helpers.py | 17 ++++++ tests/console/commands/test_config.py | 20 +++++++ tests/installation/test_pip_installer.py | 59 ++++++++++++++++++- tests/masonry/publishing/test_publisher.py | 68 +++++++++++++++++++++- tests/utils/test_helpers.py | 17 +++++- 11 files changed, 253 insertions(+), 16 deletions(-) diff --git a/poetry/console/commands/config.py b/poetry/console/commands/config.py index b7e4ed2790f..919f1c8410d 100644 --- a/poetry/console/commands/config.py +++ b/poetry/console/commands/config.py @@ -223,6 +223,27 @@ def handle(self): return 0 + # handle certs + m = re.match( + r"(?:certificates)\.([^.]+)\.(custom-ca|client-cert)", self.argument("key") + ) + if m: + if self.option("unset"): + auth_config_source.remove_property( + "certificates.{}.{}".format(m.group(1), m.group(2)) + ) + + return 0 + + if len(values) == 1: + auth_config_source.add_property( + "certificates.{}.{}".format(m.group(1), m.group(2)), values[0] + ) + else: + raise ValueError("You must pass exactly 1 value") + + return 0 + raise ValueError("Setting {} does not exist".format(self.argument("key"))) def _handle_single_value(self, source, key, callbacks, values): diff --git a/poetry/installation/pip_installer.py b/poetry/installation/pip_installer.py index 1afd23f3443..73b2f008058 100644 --- a/poetry/installation/pip_installer.py +++ b/poetry/installation/pip_installer.py @@ -51,6 +51,12 @@ def install(self, package, update=False): ) args += ["--trusted-host", parsed.hostname] + if repository.custom_ca: + args += ["--cert", str(repository.custom_ca)] + + if repository.client_cert: + args += ["--client-cert", str(repository.client_cert)] + index_url = repository.authenticated_url args += ["--index-url", index_url] diff --git a/poetry/masonry/publishing/publisher.py b/poetry/masonry/publishing/publisher.py index 21086608959..a3d54faaa6c 100644 --- a/poetry/masonry/publishing/publisher.py +++ b/poetry/masonry/publishing/publisher.py @@ -1,6 +1,6 @@ import logging -from poetry.utils.helpers import get_http_basic_auth +from poetry.utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth from .uploader import Uploader @@ -75,14 +75,16 @@ def publish(self, repository_name, username, password): password = auth[1] # Requesting missing credentials - if not username: + if username is None: username = self._io.ask("Username:") if password is None: password = self._io.ask_hidden("Password:") - # TODO: handle certificates - self._uploader.auth(username, password) - return self._uploader.upload(url) + return self._uploader.upload( + url, + custom_ca=get_custom_ca(self._poetry.config, repository_name), + client_cert=get_client_cert(self._poetry.config, repository_name), + ) diff --git a/poetry/masonry/publishing/uploader.py b/poetry/masonry/publishing/uploader.py index fd84b26959c..4cd60a51495 100644 --- a/poetry/masonry/publishing/uploader.py +++ b/poetry/masonry/publishing/uploader.py @@ -3,7 +3,7 @@ import math import re -from typing import List +from typing import List, Optional import requests @@ -14,6 +14,7 @@ from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor from poetry.__version__ import __version__ +from poetry.utils._compat import Path from poetry.utils.helpers import normalize_version from poetry.utils.patterns import wheel_file_re @@ -94,9 +95,17 @@ def make_session(self): def is_authenticated(self): return self._username is not None and self._password is not None - def upload(self, url): + def upload( + self, url, custom_ca=None, client_cert=None + ): # type: (str, Optional[Path], Optional[Path]) -> None session = self.make_session() + if custom_ca: + session.verify = str(custom_ca) + + if client_cert: + session.cert = str(client_cert) + try: self._upload(session, url) finally: diff --git a/poetry/poetry.py b/poetry/poetry.py index 3c53244830b..5f5db3d2198 100644 --- a/poetry/poetry.py +++ b/poetry/poetry.py @@ -20,7 +20,7 @@ from .repositories.pypi_repository import PyPiRepository from .spdx import license_by_id from .utils._compat import Path -from .utils.helpers import get_http_basic_auth +from .utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth from .utils.toml_file import TomlFile @@ -232,7 +232,13 @@ def create_legacy_repository( auth = Auth(url, credentials[0], credentials[1]) - return LegacyRepository(name, url, auth=auth) + return LegacyRepository( + name, + url, + auth=auth, + custom_ca=get_custom_ca(self._config, name), + client_cert=get_client_cert(self._config, name), + ) @classmethod def locate(cls, cwd): # type: (Path) -> Poetry diff --git a/poetry/repositories/legacy_repository.py b/poetry/repositories/legacy_repository.py index dea6f5bd640..4ccaefe22b8 100644 --- a/poetry/repositories/legacy_repository.py +++ b/poetry/repositories/legacy_repository.py @@ -155,8 +155,14 @@ def clean_link(self, url): class LegacyRepository(PyPiRepository): def __init__( - self, name, url, auth=None, disable_cache=False - ): # type: (str, str, Optional[Auth], bool) -> None + self, + name, + url, + auth=None, + disable_cache=False, + custom_ca=None, + client_cert=None, + ): # type: (str, str, Optional[Auth], bool, Optional[Path], Optional[Path]) -> None if name == "pypi": raise ValueError("The name [pypi] is reserved for repositories") @@ -164,6 +170,8 @@ def __init__( self._name = name self._url = url.rstrip("/") self._auth = auth + self._client_cert = client_cert + self._custom_ca = custom_ca self._inspector = Inspector() self._cache_dir = Path(CACHE_DIR) / "cache" / "repositories" / name self._cache = CacheManager( @@ -186,8 +194,22 @@ def __init__( if not url_parts.username and self._auth: self._session.auth = self._auth + if self._custom_ca: + self._session.verify = self._custom_ca + + if self._client_cert: + self._session.cert = self._client_cert + self._disable_cache = disable_cache + @property + def custom_ca(self): # type: () -> Optional[Path] + return self._custom_ca + + @property + def client_cert(self): # type: () -> Optional[Path] + return self._client_cert + @property def authenticated_url(self): # type: () -> str if not self._auth: diff --git a/poetry/utils/helpers.py b/poetry/utils/helpers.py index 1e7bc660424..f0b62e633af 100644 --- a/poetry/utils/helpers.py +++ b/poetry/utils/helpers.py @@ -14,6 +14,7 @@ from poetry.config.config import Config from poetry.version import Version +from poetry.utils._compat import Path _canonicalize_regex = re.compile("[-_]+") @@ -133,6 +134,22 @@ def get_http_basic_auth( return None +def get_custom_ca(config, repository_name): # type: (Config, str) -> Optional[Path] + custom_ca = config.get("certificates.{}.custom-ca".format(repository_name)) + if custom_ca: + return Path(custom_ca) + else: + return None + + +def get_client_cert(config, repository_name): # type: (Config, str) -> Optional[Path] + client_cert = config.get("certificates.{}.client-cert".format(repository_name)) + if client_cert: + return Path(client_cert) + else: + return None + + def _on_rm_error(func, path, exc_info): os.chmod(path, stat.S_IWRITE) func(path) diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index 94e4000c06a..8a7221e1210 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -105,3 +105,23 @@ def test_set_pypi_token(app, config_source, config_document, mocker): tester.execute("--list") assert "mytoken" == config_document["pypi-token"]["pypi"] + + +def test_set_client_cert(app, config_source, config_document, mocker): + init = mocker.spy(ConfigSource, "__init__") + command = app.find("config") + tester = CommandTester(command) + + tester.execute("certificates.foo.client-cert path/to/cert.pem") + + assert "path/to/cert.pem" == config_document["certificates"]["foo"]["client-cert"] + + +def test_set_custom_ca(app, config_source, config_document, mocker): + init = mocker.spy(ConfigSource, "__init__") + command = app.find("config") + tester = CommandTester(command) + + tester.execute("certificates.foo.custom-ca path/to/ca.pem") + + assert "path/to/ca.pem" == config_document["certificates"]["foo"]["custom-ca"] diff --git a/tests/installation/test_pip_installer.py b/tests/installation/test_pip_installer.py index b901bf7f560..08418e91844 100644 --- a/tests/installation/test_pip_installer.py +++ b/tests/installation/test_pip_installer.py @@ -3,6 +3,7 @@ from poetry.packages.package import Package from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.pool import Pool +from poetry.utils._compat import Path from poetry.utils.env import NullEnv @@ -29,7 +30,7 @@ def test_requirement(): def test_install_with_non_pypi_default_repository(): pool = Pool() - default = LegacyRepository("default", "https://default.com") + default = LegacyRepository("default", "https://foo.bar") another = LegacyRepository("another", "https://another.com") pool.add_repository(default, default=True) @@ -48,3 +49,59 @@ def test_install_with_non_pypi_default_repository(): installer.install(foo) installer.install(bar) + + +def test_install_with_custom_ca(): + ca_path = "path/to/custom_ca.pem" + pool = Pool() + + default = LegacyRepository("default", "https://foo.bar", custom_ca=Path(ca_path)) + + pool.add_repository(default, default=True) + + null_env = NullEnv() + + installer = PipInstaller(null_env, NullIO(), pool) + + foo = Package("foo", "0.0.0") + foo.source_type = "legacy" + foo.source_reference = default._name + foo.source_url = default._url + + installer.install(foo) + + assert len(null_env.executed) == 1 + cmd = null_env.executed[0] + assert "--cert" in cmd + cert_index = cmd.index("--cert") + # Need to do the str(Path()) bit because Windows paths get modified by Path + assert cmd[cert_index + 1] == str(Path(ca_path)) + + +def test_install_with_client_cert(): + client_path = "path/to/client.pem" + pool = Pool() + + default = LegacyRepository( + "default", "https://foo.bar", client_cert=Path(client_path) + ) + + pool.add_repository(default, default=True) + + null_env = NullEnv() + + installer = PipInstaller(null_env, NullIO(), pool) + + foo = Package("foo", "0.0.0") + foo.source_type = "legacy" + foo.source_reference = default._name + foo.source_url = default._url + + installer.install(foo) + + assert len(null_env.executed) == 1 + cmd = null_env.executed[0] + assert "--client-cert" in cmd + cert_index = cmd.index("--client-cert") + # Need to do the str(Path()) bit because Windows paths get modified by Path + assert cmd[cert_index + 1] == str(Path(client_path)) diff --git a/tests/masonry/publishing/test_publisher.py b/tests/masonry/publishing/test_publisher.py index 01399aeace3..e4b25ef59af 100644 --- a/tests/masonry/publishing/test_publisher.py +++ b/tests/masonry/publishing/test_publisher.py @@ -3,6 +3,7 @@ from poetry.io.null_io import NullIO from poetry.masonry.publishing.publisher import Publisher from poetry.poetry import Poetry +from poetry.utils._compat import Path def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): @@ -18,7 +19,10 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): publisher.publish(None, None, None) assert [("foo", "bar")] == uploader_auth.call_args - assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args + assert [ + ("https://upload.pypi.org/legacy/",), + {"custom_ca": None, "client_cert": None}, + ] == uploader_upload.call_args def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): @@ -37,7 +41,10 @@ def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): publisher.publish("my-repo", None, None) assert [("foo", "bar")] == uploader_auth.call_args - assert [("http://foo.bar",)] == uploader_upload.call_args + assert [ + ("http://foo.bar",), + {"custom_ca": None, "client_cert": None}, + ] == uploader_upload.call_args def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker, config): @@ -63,4 +70,59 @@ def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config): publisher.publish(None, None, None) assert [("__token__", "my-token")] == uploader_auth.call_args - assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args + assert [ + ("https://upload.pypi.org/legacy/",), + {"custom_ca": None, "client_cert": None}, + ] == uploader_upload.call_args + + +def test_publish_uses_custom_ca(fixture_dir, mocker, config): + custom_ca = "path/to/ca.pem" + uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") + uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") + poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config + # Adding the empty string for username/password is a bit of a hack but otherwise it will prompt for them + # username and password are optional but there is no good way of specifying a lack of them other than empty string + poetry.config.merge( + { + "repositories": {"foo": {"url": "https://foo.bar"}}, + "http-basic": {"foo": {"username": "", "password": ""}}, + "certificates": {"foo": {"custom-ca": custom_ca}}, + } + ) + publisher = Publisher(poetry, NullIO()) + + publisher.publish("foo", None, None) + + assert [("", "")] == uploader_auth.call_args + assert [ + ("https://foo.bar",), + {"custom_ca": Path(custom_ca), "client_cert": None}, + ] == uploader_upload.call_args + + +def test_publish_uses_client_cert(fixture_dir, mocker, config): + client_cert = "path/to/client.pem" + uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") + uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") + poetry = Poetry.create(fixture_dir("sample_project")) + poetry._config = config + # Adding the empty string for username/password is a bit of a hack but otherwise it will prompt for them + # username and password are optional but there is no good way of specifying a lack of them other than empty string + poetry.config.merge( + { + "repositories": {"foo": {"url": "https://foo.bar"}}, + "http-basic": {"foo": {"username": "", "password": ""}}, + "certificates": {"foo": {"client-cert": client_cert}}, + } + ) + publisher = Publisher(poetry, NullIO()) + + publisher.publish("foo", None, None) + + assert [("", "")] == uploader_auth.call_args + assert [ + ("https://foo.bar",), + {"custom_ca": None, "client_cert": Path(client_cert)}, + ] == uploader_upload.call_args diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 77ea9ff12b7..15631597b20 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,4 +1,5 @@ -from poetry.utils.helpers import get_http_basic_auth +from poetry.utils._compat import Path +from poetry.utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth from poetry.utils.helpers import parse_requires @@ -65,3 +66,17 @@ def test_get_http_basic_auth_without_password(config): def test_get_http_basic_auth_missing(config): assert get_http_basic_auth(config, "foo") is None + + +def test_get_custom_ca(config): + ca_cert = "path/to/ca.pem" + config.merge({"certificates": {"foo": {"custom-ca": ca_cert}}}) + + assert get_custom_ca(config, "foo") == Path(ca_cert) + + +def test_get_client_cert(config): + client_cert = "path/to/client.pem" + config.merge({"certificates": {"foo": {"client-cert": client_cert}}}) + + assert get_client_cert(config, "foo") == Path(client_cert) From 07aba89908182f3d2ea51970bac1bdad01993f61 Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Sun, 25 Aug 2019 12:03:40 +0100 Subject: [PATCH 2/6] Add documentation on certificates --- docs/docs/repositories.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/docs/repositories.md b/docs/docs/repositories.md index 73fb42f321b..d3db5caa59c 100644 --- a/docs/docs/repositories.md +++ b/docs/docs/repositories.md @@ -74,6 +74,16 @@ export POETRY_HTTP_BASIC_PYPI_PASSWORD=password See [Using environment variables](/configuration#using-environment-variables) for more information on how to configure Poetry with environment variables. +#### Custom certificate authority and mutual TLS authentication +Poetry supports repositories that are secured by a custom certificate authority as well as those that require +certificate-based client authentication. The following will configure the "foo" repository to validate the repository's +certificate using a custom certificate authority and use a client certificate (note that these config variables do not +both need to be set): +```bash + poetry config certificates.foo.custom-ca /path/to/ca.pem + poetry config certificates.foo.client-cert /path/to/client.pem +``` + ### Install dependencies from a private repository Now that you can publish to your private repository, you need to be able to @@ -105,8 +115,10 @@ From now on, Poetry will also look for packages in your private repository. If your private repository requires HTTP Basic Auth be sure to add the username and password to your `http-basic` configuration using the example above (be sure to use the -same name that is in the `tool.poetry.source` section). Poetry will use these values -to authenticate to your private repository when downloading or looking for packages. +same name that is in the `tool.poetry.source` section). If your repository requires either +a custom certificate authority or client certificates, similarly refer to the example above to configure the +`certificates` section. Poetry will use these values to authenticate to your private repository when downloading or +looking for packages. ### Disabling the PyPI repository From 35577b0175e596413fa41ebb32db9f4748f26c5e Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Sun, 25 Aug 2019 14:02:44 +0100 Subject: [PATCH 3/6] Add cli options to publish for cert and custom ca --- poetry/console/commands/publish.py | 25 +++++++++++++++++++++- poetry/masonry/publishing/publisher.py | 22 ++++++++++++------- tests/console/commands/test_publish.py | 22 +++++++++++++++++++ tests/masonry/publishing/test_publisher.py | 11 ++-------- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/poetry/console/commands/publish.py b/poetry/console/commands/publish.py index b888ea045b0..1b5ae2e0276 100644 --- a/poetry/console/commands/publish.py +++ b/poetry/console/commands/publish.py @@ -1,5 +1,7 @@ from cleo import option +from poetry.utils._compat import Path + from .command import Command @@ -14,6 +16,18 @@ class PublishCommand(Command): ), option("username", "u", "The username to access the repository.", flag=False), option("password", "p", "The password to access the repository.", flag=False), + option( + "custom-ca", + None, + "Certificate authority to access the repository.", + flag=False, + ), + option( + "client-cert", + None, + "Client certificate to access the repository.", + flag=False, + ), option("build", None, "Build the package before publishing."), ] @@ -57,6 +71,15 @@ def handle(self): self.line("") + custom_ca = Path(self.option("custom-ca")) if self.option("custom-ca") else None + client_cert = ( + Path(self.option("client-cert")) if self.option("client-cert") else None + ) + publisher.publish( - self.option("repository"), self.option("username"), self.option("password") + self.option("repository"), + self.option("username"), + self.option("password"), + custom_ca, + client_cert, ) diff --git a/poetry/masonry/publishing/publisher.py b/poetry/masonry/publishing/publisher.py index a3d54faaa6c..b67ff090ca4 100644 --- a/poetry/masonry/publishing/publisher.py +++ b/poetry/masonry/publishing/publisher.py @@ -23,7 +23,9 @@ def __init__(self, poetry, io): def files(self): return self._uploader.files - def publish(self, repository_name, username, password): + def publish( + self, repository_name, username, password, custom_ca=None, client_cert=None + ): if repository_name: self._io.write_line( "Publishing {} ({}) " @@ -74,17 +76,21 @@ def publish(self, repository_name, username, password): username = auth[0] password = auth[1] - # Requesting missing credentials - if username is None: - username = self._io.ask("Username:") + resolved_client_cert = client_cert or get_client_cert( + self._poetry.config, repository_name + ) + # Requesting missing credentials but only if there is not a client cert defined. + if not resolved_client_cert: + if username is None: + username = self._io.ask("Username:") - if password is None: - password = self._io.ask_hidden("Password:") + if password is None: + password = self._io.ask_hidden("Password:") self._uploader.auth(username, password) return self._uploader.upload( url, - custom_ca=get_custom_ca(self._poetry.config, repository_name), - client_cert=get_client_cert(self._poetry.config, repository_name), + custom_ca=custom_ca or get_custom_ca(self._poetry.config, repository_name), + client_cert=resolved_client_cert, ) diff --git a/tests/console/commands/test_publish.py b/tests/console/commands/test_publish.py index 5d60244760b..eb7f597f737 100644 --- a/tests/console/commands/test_publish.py +++ b/tests/console/commands/test_publish.py @@ -1,3 +1,6 @@ +from poetry.utils._compat import Path + + def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http): http.register_uri( http.POST, "https://upload.pypi.org/legacy/", status=400, body="Bad Request" @@ -16,3 +19,22 @@ def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http): """ assert app_tester.io.fetch_output() == expected + + +def test_publish_with_custom_ca(app_tester, mocker): + publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish") + + app_tester.execute("publish --custom-ca path/to/ca.pem") + + assert [ + (None, None, None, Path("path/to/ca.pem"), None) + ] == publisher_publish.call_args + + +def test_publish_with_client_cert(app_tester, mocker): + publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish") + + app_tester.execute("publish --client-cert path/to/client.pem") + assert [ + (None, None, None, None, Path("path/to/client.pem")) + ] == publisher_publish.call_args diff --git a/tests/masonry/publishing/test_publisher.py b/tests/masonry/publishing/test_publisher.py index e4b25ef59af..c474ded93e9 100644 --- a/tests/masonry/publishing/test_publisher.py +++ b/tests/masonry/publishing/test_publisher.py @@ -82,12 +82,10 @@ def test_publish_uses_custom_ca(fixture_dir, mocker, config): uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") poetry = Poetry.create(fixture_dir("sample_project")) poetry._config = config - # Adding the empty string for username/password is a bit of a hack but otherwise it will prompt for them - # username and password are optional but there is no good way of specifying a lack of them other than empty string poetry.config.merge( { "repositories": {"foo": {"url": "https://foo.bar"}}, - "http-basic": {"foo": {"username": "", "password": ""}}, + "http-basic": {"foo": {"username": "foo", "password": "bar"}}, "certificates": {"foo": {"custom-ca": custom_ca}}, } ) @@ -95,7 +93,7 @@ def test_publish_uses_custom_ca(fixture_dir, mocker, config): publisher.publish("foo", None, None) - assert [("", "")] == uploader_auth.call_args + assert [("foo", "bar")] == uploader_auth.call_args assert [ ("https://foo.bar",), {"custom_ca": Path(custom_ca), "client_cert": None}, @@ -104,16 +102,12 @@ def test_publish_uses_custom_ca(fixture_dir, mocker, config): def test_publish_uses_client_cert(fixture_dir, mocker, config): client_cert = "path/to/client.pem" - uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") poetry = Poetry.create(fixture_dir("sample_project")) poetry._config = config - # Adding the empty string for username/password is a bit of a hack but otherwise it will prompt for them - # username and password are optional but there is no good way of specifying a lack of them other than empty string poetry.config.merge( { "repositories": {"foo": {"url": "https://foo.bar"}}, - "http-basic": {"foo": {"username": "", "password": ""}}, "certificates": {"foo": {"client-cert": client_cert}}, } ) @@ -121,7 +115,6 @@ def test_publish_uses_client_cert(fixture_dir, mocker, config): publisher.publish("foo", None, None) - assert [("", "")] == uploader_auth.call_args assert [ ("https://foo.bar",), {"custom_ca": None, "client_cert": Path(client_cert)}, From 3cd115521c2d8ca651e96774f585a53415d6e76a Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Tue, 27 Aug 2019 16:49:04 +0100 Subject: [PATCH 4/6] Fix requests+pathlib problem and logic hole when BasicAuth+certs are used --- poetry/poetry.py | 8 ++++---- poetry/repositories/legacy_repository.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry/poetry.py b/poetry/poetry.py index 5f5db3d2198..841e89c10ad 100644 --- a/poetry/poetry.py +++ b/poetry/poetry.py @@ -227,10 +227,10 @@ def create_legacy_repository( name = source["name"] url = source["url"] credentials = get_http_basic_auth(self._config, name) - if not credentials: - return LegacyRepository(name, url) - - auth = Auth(url, credentials[0], credentials[1]) + if credentials: + auth = Auth(url, credentials[0], credentials[1]) + else: + auth = None return LegacyRepository( name, diff --git a/poetry/repositories/legacy_repository.py b/poetry/repositories/legacy_repository.py index 4ccaefe22b8..ae12b4ce5fc 100644 --- a/poetry/repositories/legacy_repository.py +++ b/poetry/repositories/legacy_repository.py @@ -195,10 +195,10 @@ def __init__( self._session.auth = self._auth if self._custom_ca: - self._session.verify = self._custom_ca + self._session.verify = str(self._custom_ca) if self._client_cert: - self._session.cert = self._client_cert + self._session.cert = str(self._client_cert) self._disable_cache = disable_cache From f7d0d1d96733e7e83ca4d7261661c749474590ab Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Sat, 21 Sep 2019 18:10:55 +0100 Subject: [PATCH 5/6] Rename custom-ca to cert --- docs/docs/repositories.md | 2 +- poetry/console/commands/config.py | 2 +- poetry/console/commands/publish.py | 9 +++------ poetry/installation/pip_installer.py | 4 ++-- poetry/masonry/publishing/publisher.py | 8 +++----- poetry/masonry/publishing/uploader.py | 6 +++--- poetry/poetry.py | 4 ++-- poetry/repositories/legacy_repository.py | 18 ++++++------------ poetry/utils/helpers.py | 8 ++++---- tests/console/commands/test_config.py | 6 +++--- tests/console/commands/test_publish.py | 4 ++-- tests/installation/test_pip_installer.py | 6 +++--- tests/masonry/publishing/test_publisher.py | 16 ++++++++-------- tests/utils/test_helpers.py | 8 ++++---- 14 files changed, 45 insertions(+), 56 deletions(-) diff --git a/docs/docs/repositories.md b/docs/docs/repositories.md index d3db5caa59c..e39ec46fe5b 100644 --- a/docs/docs/repositories.md +++ b/docs/docs/repositories.md @@ -80,7 +80,7 @@ certificate-based client authentication. The following will configure the "foo" certificate using a custom certificate authority and use a client certificate (note that these config variables do not both need to be set): ```bash - poetry config certificates.foo.custom-ca /path/to/ca.pem + poetry config certificates.foo.cert /path/to/ca.pem poetry config certificates.foo.client-cert /path/to/client.pem ``` diff --git a/poetry/console/commands/config.py b/poetry/console/commands/config.py index 6df18d932bc..4c2140d621c 100644 --- a/poetry/console/commands/config.py +++ b/poetry/console/commands/config.py @@ -226,7 +226,7 @@ def handle(self): # handle certs m = re.match( - r"(?:certificates)\.([^.]+)\.(custom-ca|client-cert)", self.argument("key") + r"(?:certificates)\.([^.]+)\.(cert|client-cert)", self.argument("key") ) if m: if self.option("unset"): diff --git a/poetry/console/commands/publish.py b/poetry/console/commands/publish.py index 1b5ae2e0276..edbdadb140d 100644 --- a/poetry/console/commands/publish.py +++ b/poetry/console/commands/publish.py @@ -17,10 +17,7 @@ class PublishCommand(Command): option("username", "u", "The username to access the repository.", flag=False), option("password", "p", "The password to access the repository.", flag=False), option( - "custom-ca", - None, - "Certificate authority to access the repository.", - flag=False, + "cert", None, "Certificate authority to access the repository.", flag=False ), option( "client-cert", @@ -71,7 +68,7 @@ def handle(self): self.line("") - custom_ca = Path(self.option("custom-ca")) if self.option("custom-ca") else None + cert = Path(self.option("cert")) if self.option("cert") else None client_cert = ( Path(self.option("client-cert")) if self.option("client-cert") else None ) @@ -80,6 +77,6 @@ def handle(self): self.option("repository"), self.option("username"), self.option("password"), - custom_ca, + cert, client_cert, ) diff --git a/poetry/installation/pip_installer.py b/poetry/installation/pip_installer.py index 73b2f008058..dd2bf4dfc8a 100644 --- a/poetry/installation/pip_installer.py +++ b/poetry/installation/pip_installer.py @@ -51,8 +51,8 @@ def install(self, package, update=False): ) args += ["--trusted-host", parsed.hostname] - if repository.custom_ca: - args += ["--cert", str(repository.custom_ca)] + if repository.cert: + args += ["--cert", str(repository.cert)] if repository.client_cert: args += ["--client-cert", str(repository.client_cert)] diff --git a/poetry/masonry/publishing/publisher.py b/poetry/masonry/publishing/publisher.py index b67ff090ca4..c62f837afa4 100644 --- a/poetry/masonry/publishing/publisher.py +++ b/poetry/masonry/publishing/publisher.py @@ -1,6 +1,6 @@ import logging -from poetry.utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth +from poetry.utils.helpers import get_client_cert, get_cert, get_http_basic_auth from .uploader import Uploader @@ -23,9 +23,7 @@ def __init__(self, poetry, io): def files(self): return self._uploader.files - def publish( - self, repository_name, username, password, custom_ca=None, client_cert=None - ): + def publish(self, repository_name, username, password, cert=None, client_cert=None): if repository_name: self._io.write_line( "Publishing {} ({}) " @@ -91,6 +89,6 @@ def publish( return self._uploader.upload( url, - custom_ca=custom_ca or get_custom_ca(self._poetry.config, repository_name), + cert=cert or get_cert(self._poetry.config, repository_name), client_cert=resolved_client_cert, ) diff --git a/poetry/masonry/publishing/uploader.py b/poetry/masonry/publishing/uploader.py index 4cd60a51495..cac6e2af7aa 100644 --- a/poetry/masonry/publishing/uploader.py +++ b/poetry/masonry/publishing/uploader.py @@ -96,12 +96,12 @@ def is_authenticated(self): return self._username is not None and self._password is not None def upload( - self, url, custom_ca=None, client_cert=None + self, url, cert=None, client_cert=None ): # type: (str, Optional[Path], Optional[Path]) -> None session = self.make_session() - if custom_ca: - session.verify = str(custom_ca) + if cert: + session.verify = str(cert) if client_cert: session.cert = str(client_cert) diff --git a/poetry/poetry.py b/poetry/poetry.py index 841e89c10ad..0663698af83 100644 --- a/poetry/poetry.py +++ b/poetry/poetry.py @@ -20,7 +20,7 @@ from .repositories.pypi_repository import PyPiRepository from .spdx import license_by_id from .utils._compat import Path -from .utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth +from .utils.helpers import get_client_cert, get_cert, get_http_basic_auth from .utils.toml_file import TomlFile @@ -236,7 +236,7 @@ def create_legacy_repository( name, url, auth=auth, - custom_ca=get_custom_ca(self._config, name), + cert=get_cert(self._config, name), client_cert=get_client_cert(self._config, name), ) diff --git a/poetry/repositories/legacy_repository.py b/poetry/repositories/legacy_repository.py index ae12b4ce5fc..998d5275f3c 100644 --- a/poetry/repositories/legacy_repository.py +++ b/poetry/repositories/legacy_repository.py @@ -155,13 +155,7 @@ def clean_link(self, url): class LegacyRepository(PyPiRepository): def __init__( - self, - name, - url, - auth=None, - disable_cache=False, - custom_ca=None, - client_cert=None, + self, name, url, auth=None, disable_cache=False, cert=None, client_cert=None ): # type: (str, str, Optional[Auth], bool, Optional[Path], Optional[Path]) -> None if name == "pypi": raise ValueError("The name [pypi] is reserved for repositories") @@ -171,7 +165,7 @@ def __init__( self._url = url.rstrip("/") self._auth = auth self._client_cert = client_cert - self._custom_ca = custom_ca + self._cert = cert self._inspector = Inspector() self._cache_dir = Path(CACHE_DIR) / "cache" / "repositories" / name self._cache = CacheManager( @@ -194,8 +188,8 @@ def __init__( if not url_parts.username and self._auth: self._session.auth = self._auth - if self._custom_ca: - self._session.verify = str(self._custom_ca) + if self._cert: + self._session.verify = str(self._cert) if self._client_cert: self._session.cert = str(self._client_cert) @@ -203,8 +197,8 @@ def __init__( self._disable_cache = disable_cache @property - def custom_ca(self): # type: () -> Optional[Path] - return self._custom_ca + def cert(self): # type: () -> Optional[Path] + return self._cert @property def client_cert(self): # type: () -> Optional[Path] diff --git a/poetry/utils/helpers.py b/poetry/utils/helpers.py index f0b62e633af..2aefaf6c024 100644 --- a/poetry/utils/helpers.py +++ b/poetry/utils/helpers.py @@ -134,10 +134,10 @@ def get_http_basic_auth( return None -def get_custom_ca(config, repository_name): # type: (Config, str) -> Optional[Path] - custom_ca = config.get("certificates.{}.custom-ca".format(repository_name)) - if custom_ca: - return Path(custom_ca) +def get_cert(config, repository_name): # type: (Config, str) -> Optional[Path] + cert = config.get("certificates.{}.cert".format(repository_name)) + if cert: + return Path(cert) else: return None diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index 8a7221e1210..cb0f64ae9f7 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -117,11 +117,11 @@ def test_set_client_cert(app, config_source, config_document, mocker): assert "path/to/cert.pem" == config_document["certificates"]["foo"]["client-cert"] -def test_set_custom_ca(app, config_source, config_document, mocker): +def test_set_cert(app, config_source, config_document, mocker): init = mocker.spy(ConfigSource, "__init__") command = app.find("config") tester = CommandTester(command) - tester.execute("certificates.foo.custom-ca path/to/ca.pem") + tester.execute("certificates.foo.cert path/to/ca.pem") - assert "path/to/ca.pem" == config_document["certificates"]["foo"]["custom-ca"] + assert "path/to/ca.pem" == config_document["certificates"]["foo"]["cert"] diff --git a/tests/console/commands/test_publish.py b/tests/console/commands/test_publish.py index eb7f597f737..856878a57a4 100644 --- a/tests/console/commands/test_publish.py +++ b/tests/console/commands/test_publish.py @@ -21,10 +21,10 @@ def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http): assert app_tester.io.fetch_output() == expected -def test_publish_with_custom_ca(app_tester, mocker): +def test_publish_with_cert(app_tester, mocker): publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish") - app_tester.execute("publish --custom-ca path/to/ca.pem") + app_tester.execute("publish --cert path/to/ca.pem") assert [ (None, None, None, Path("path/to/ca.pem"), None) diff --git a/tests/installation/test_pip_installer.py b/tests/installation/test_pip_installer.py index 08418e91844..26798160496 100644 --- a/tests/installation/test_pip_installer.py +++ b/tests/installation/test_pip_installer.py @@ -51,11 +51,11 @@ def test_install_with_non_pypi_default_repository(): installer.install(bar) -def test_install_with_custom_ca(): - ca_path = "path/to/custom_ca.pem" +def test_install_with_cert(): + ca_path = "path/to/cert.pem" pool = Pool() - default = LegacyRepository("default", "https://foo.bar", custom_ca=Path(ca_path)) + default = LegacyRepository("default", "https://foo.bar", cert=Path(ca_path)) pool.add_repository(default, default=True) diff --git a/tests/masonry/publishing/test_publisher.py b/tests/masonry/publishing/test_publisher.py index c474ded93e9..b44f773e61f 100644 --- a/tests/masonry/publishing/test_publisher.py +++ b/tests/masonry/publishing/test_publisher.py @@ -21,7 +21,7 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): assert [("foo", "bar")] == uploader_auth.call_args assert [ ("https://upload.pypi.org/legacy/",), - {"custom_ca": None, "client_cert": None}, + {"cert": None, "client_cert": None}, ] == uploader_upload.call_args @@ -43,7 +43,7 @@ def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): assert [("foo", "bar")] == uploader_auth.call_args assert [ ("http://foo.bar",), - {"custom_ca": None, "client_cert": None}, + {"cert": None, "client_cert": None}, ] == uploader_upload.call_args @@ -72,12 +72,12 @@ def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config): assert [("__token__", "my-token")] == uploader_auth.call_args assert [ ("https://upload.pypi.org/legacy/",), - {"custom_ca": None, "client_cert": None}, + {"cert": None, "client_cert": None}, ] == uploader_upload.call_args -def test_publish_uses_custom_ca(fixture_dir, mocker, config): - custom_ca = "path/to/ca.pem" +def test_publish_uses_cert(fixture_dir, mocker, config): + cert = "path/to/ca.pem" uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth") uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload") poetry = Poetry.create(fixture_dir("sample_project")) @@ -86,7 +86,7 @@ def test_publish_uses_custom_ca(fixture_dir, mocker, config): { "repositories": {"foo": {"url": "https://foo.bar"}}, "http-basic": {"foo": {"username": "foo", "password": "bar"}}, - "certificates": {"foo": {"custom-ca": custom_ca}}, + "certificates": {"foo": {"cert": cert}}, } ) publisher = Publisher(poetry, NullIO()) @@ -96,7 +96,7 @@ def test_publish_uses_custom_ca(fixture_dir, mocker, config): assert [("foo", "bar")] == uploader_auth.call_args assert [ ("https://foo.bar",), - {"custom_ca": Path(custom_ca), "client_cert": None}, + {"cert": Path(cert), "client_cert": None}, ] == uploader_upload.call_args @@ -117,5 +117,5 @@ def test_publish_uses_client_cert(fixture_dir, mocker, config): assert [ ("https://foo.bar",), - {"custom_ca": None, "client_cert": Path(client_cert)}, + {"cert": None, "client_cert": Path(client_cert)}, ] == uploader_upload.call_args diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 15631597b20..18c00ff5f6f 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,5 +1,5 @@ from poetry.utils._compat import Path -from poetry.utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth +from poetry.utils.helpers import get_client_cert, get_cert, get_http_basic_auth from poetry.utils.helpers import parse_requires @@ -68,11 +68,11 @@ def test_get_http_basic_auth_missing(config): assert get_http_basic_auth(config, "foo") is None -def test_get_custom_ca(config): +def test_get_cert(config): ca_cert = "path/to/ca.pem" - config.merge({"certificates": {"foo": {"custom-ca": ca_cert}}}) + config.merge({"certificates": {"foo": {"cert": ca_cert}}}) - assert get_custom_ca(config, "foo") == Path(ca_cert) + assert get_cert(config, "foo") == Path(ca_cert) def test_get_client_cert(config): From 8ee4c89094c33bd712164fbc55a8d06af6c017ca Mon Sep 17 00:00:00 2001 From: Brian Turek Date: Sun, 20 Oct 2019 20:07:11 +0100 Subject: [PATCH 6/6] Make black happy --- tests/installation/test_pip_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/installation/test_pip_installer.py b/tests/installation/test_pip_installer.py index 4bcd4c62df6..fe40328aa1a 100644 --- a/tests/installation/test_pip_installer.py +++ b/tests/installation/test_pip_installer.py @@ -145,10 +145,10 @@ def test_install_with_client_cert(): # Need to do the str(Path()) bit because Windows paths get modified by Path assert cmd[cert_index + 1] == str(Path(client_path)) + def test_requirement_git_develop_true(installer, package_git): package_git.develop = True result = installer.requirement(package_git) expected = ["-e", "git+git@github.com:demo/demo.git@master#egg=demo"] assert expected == result -