Skip to content

Commit b91dbde

Browse files
authored
Merge pull request #11082 from uranusjr/truststore
2 parents c7d5dda + a020e8c commit b91dbde

File tree

5 files changed

+180
-11
lines changed

5 files changed

+180
-11
lines changed

news/11082.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add support to use `truststore <https://pypi.org/project/truststore/>`_ as an alternative SSL certificate verification backend. The backend can be enabled on Python 3.10 and later by installing ``truststore`` into the environment, and adding the ``--use-feature=truststore`` flag to various pip commands.
2+
3+
``truststore`` differs from the current default verification backend (provided by ``certifi``) in it uses the operating system’s trust store, which can be better controlled and augmented to better support non-standard certificates. Depending on feedback, pip may switch to this as the default certificate verification backend in the future.

src/pip/_internal/cli/cmdoptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ def check_list_path_option(options: Values) -> None:
10001000
metavar="feature",
10011001
action="append",
10021002
default=[],
1003-
choices=["2020-resolver", "fast-deps"],
1003+
choices=["2020-resolver", "fast-deps", "truststore"],
10041004
help="Enable new functionality, that may be backward incompatible.",
10051005
)
10061006

src/pip/_internal/cli/req_command.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import sys
1111
from functools import partial
1212
from optparse import Values
13-
from typing import Any, List, Optional, Tuple
13+
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
1414

1515
from pip._internal.cache import WheelCache
1616
from pip._internal.cli import cmdoptions
@@ -42,9 +42,33 @@
4242
)
4343
from pip._internal.utils.virtualenv import running_under_virtualenv
4444

45+
if TYPE_CHECKING:
46+
from ssl import SSLContext
47+
4548
logger = logging.getLogger(__name__)
4649

4750

51+
def _create_truststore_ssl_context() -> Optional["SSLContext"]:
52+
if sys.version_info < (3, 10):
53+
raise CommandError("The truststore feature is only available for Python 3.10+")
54+
55+
try:
56+
import ssl
57+
except ImportError:
58+
logger.warning("Disabling truststore since ssl support is missing")
59+
return None
60+
61+
try:
62+
import truststore
63+
except ImportError:
64+
raise CommandError(
65+
"To use the truststore feature, 'truststore' must be installed into "
66+
"pip's current environment."
67+
)
68+
69+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
70+
71+
4872
class SessionCommandMixin(CommandContextMixIn):
4973

5074
"""
@@ -84,15 +108,27 @@ def _build_session(
84108
options: Values,
85109
retries: Optional[int] = None,
86110
timeout: Optional[int] = None,
111+
fallback_to_certifi: bool = False,
87112
) -> PipSession:
88-
assert not options.cache_dir or os.path.isabs(options.cache_dir)
113+
cache_dir = options.cache_dir
114+
assert not cache_dir or os.path.isabs(cache_dir)
115+
116+
if "truststore" in options.features_enabled:
117+
try:
118+
ssl_context = _create_truststore_ssl_context()
119+
except Exception:
120+
if not fallback_to_certifi:
121+
raise
122+
ssl_context = None
123+
else:
124+
ssl_context = None
125+
89126
session = PipSession(
90-
cache=(
91-
os.path.join(options.cache_dir, "http") if options.cache_dir else None
92-
),
127+
cache=os.path.join(cache_dir, "http") if cache_dir else None,
93128
retries=retries if retries is not None else options.retries,
94129
trusted_hosts=options.trusted_hosts,
95130
index_urls=self._get_index_urls(options),
131+
ssl_context=ssl_context,
96132
)
97133

98134
# Handle custom ca-bundles from the user
@@ -142,7 +178,14 @@ def handle_pip_version_check(self, options: Values) -> None:
142178

143179
# Otherwise, check if we're using the latest version of pip available.
144180
session = self._build_session(
145-
options, retries=0, timeout=min(5, options.timeout)
181+
options,
182+
retries=0,
183+
timeout=min(5, options.timeout),
184+
# This is set to ensure the function does not fail when truststore is
185+
# specified in use-feature but cannot be loaded. This usually raises a
186+
# CommandError and shows a nice user-facing error, but this function is not
187+
# called in that try-except block.
188+
fallback_to_certifi=True,
146189
)
147190
with session:
148191
pip_self_version_check(session, options)

src/pip/_internal/network/session.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,23 @@
1515
import sys
1616
import urllib.parse
1717
import warnings
18-
from typing import Any, Dict, Generator, List, Mapping, Optional, Sequence, Tuple, Union
18+
from typing import (
19+
TYPE_CHECKING,
20+
Any,
21+
Dict,
22+
Generator,
23+
List,
24+
Mapping,
25+
Optional,
26+
Sequence,
27+
Tuple,
28+
Union,
29+
)
1930

2031
from pip._vendor import requests, urllib3
21-
from pip._vendor.cachecontrol import CacheControlAdapter
22-
from pip._vendor.requests.adapters import BaseAdapter, HTTPAdapter
32+
from pip._vendor.cachecontrol import CacheControlAdapter as _BaseCacheControlAdapter
33+
from pip._vendor.requests.adapters import DEFAULT_POOLBLOCK, BaseAdapter
34+
from pip._vendor.requests.adapters import HTTPAdapter as _BaseHTTPAdapter
2335
from pip._vendor.requests.models import PreparedRequest, Response
2436
from pip._vendor.requests.structures import CaseInsensitiveDict
2537
from pip._vendor.urllib3.connectionpool import ConnectionPool
@@ -37,6 +49,12 @@
3749
from pip._internal.utils.misc import build_url_from_netloc, parse_netloc
3850
from pip._internal.utils.urls import url_to_path
3951

52+
if TYPE_CHECKING:
53+
from ssl import SSLContext
54+
55+
from pip._vendor.urllib3.poolmanager import PoolManager
56+
57+
4058
logger = logging.getLogger(__name__)
4159

4260
SecureOrigin = Tuple[str, str, Optional[Union[int, str]]]
@@ -233,6 +251,48 @@ def close(self) -> None:
233251
pass
234252

235253

254+
class _SSLContextAdapterMixin:
255+
"""Mixin to add the ``ssl_context`` contructor argument to HTTP adapters.
256+
257+
The additional argument is forwarded directly to the pool manager. This allows us
258+
to dynamically decide what SSL store to use at runtime, which is used to implement
259+
the optional ``truststore`` backend.
260+
"""
261+
262+
def __init__(
263+
self,
264+
*,
265+
ssl_context: Optional["SSLContext"] = None,
266+
**kwargs: Any,
267+
) -> None:
268+
self._ssl_context = ssl_context
269+
super().__init__(**kwargs)
270+
271+
def init_poolmanager(
272+
self,
273+
connections: int,
274+
maxsize: int,
275+
block: bool = DEFAULT_POOLBLOCK,
276+
**pool_kwargs: Any,
277+
) -> "PoolManager":
278+
if self._ssl_context is not None:
279+
pool_kwargs.setdefault("ssl_context", self._ssl_context)
280+
return super().init_poolmanager( # type: ignore[misc]
281+
connections=connections,
282+
maxsize=maxsize,
283+
block=block,
284+
**pool_kwargs,
285+
)
286+
287+
288+
class HTTPAdapter(_SSLContextAdapterMixin, _BaseHTTPAdapter):
289+
pass
290+
291+
292+
class CacheControlAdapter(_SSLContextAdapterMixin, _BaseCacheControlAdapter):
293+
pass
294+
295+
236296
class InsecureHTTPAdapter(HTTPAdapter):
237297
def cert_verify(
238298
self,
@@ -266,6 +326,7 @@ def __init__(
266326
cache: Optional[str] = None,
267327
trusted_hosts: Sequence[str] = (),
268328
index_urls: Optional[List[str]] = None,
329+
ssl_context: Optional["SSLContext"] = None,
269330
**kwargs: Any,
270331
) -> None:
271332
"""
@@ -318,13 +379,14 @@ def __init__(
318379
secure_adapter = CacheControlAdapter(
319380
cache=SafeFileCache(cache),
320381
max_retries=retries,
382+
ssl_context=ssl_context,
321383
)
322384
self._trusted_host_adapter = InsecureCacheControlAdapter(
323385
cache=SafeFileCache(cache),
324386
max_retries=retries,
325387
)
326388
else:
327-
secure_adapter = HTTPAdapter(max_retries=retries)
389+
secure_adapter = HTTPAdapter(max_retries=retries, ssl_context=ssl_context)
328390
self._trusted_host_adapter = insecure_adapter
329391

330392
self.mount("https://", secure_adapter)

tests/functional/test_truststore.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import sys
2+
from typing import Any, Callable
3+
4+
import pytest
5+
6+
from tests.lib import PipTestEnvironment, TestPipResult
7+
8+
PipRunner = Callable[..., TestPipResult]
9+
10+
11+
@pytest.fixture()
12+
def pip(script: PipTestEnvironment) -> PipRunner:
13+
def pip(*args: str, **kwargs: Any) -> TestPipResult:
14+
return script.pip(*args, "--use-feature=truststore", **kwargs)
15+
16+
return pip
17+
18+
19+
@pytest.mark.skipif(sys.version_info >= (3, 10), reason="3.10 can run truststore")
20+
def test_truststore_error_on_old_python(pip: PipRunner) -> None:
21+
result = pip(
22+
"install",
23+
"--no-index",
24+
"does-not-matter",
25+
expect_error=True,
26+
)
27+
assert "The truststore feature is only available for Python 3.10+" in result.stderr
28+
29+
30+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="3.10+ required for truststore")
31+
def test_truststore_error_without_preinstalled(pip: PipRunner) -> None:
32+
result = pip(
33+
"install",
34+
"--no-index",
35+
"does-not-matter",
36+
expect_error=True,
37+
)
38+
assert (
39+
"To use the truststore feature, 'truststore' must be installed into "
40+
"pip's current environment."
41+
) in result.stderr
42+
43+
44+
@pytest.mark.skipif(sys.version_info < (3, 10), reason="3.10+ required for truststore")
45+
@pytest.mark.network
46+
@pytest.mark.parametrize(
47+
"package",
48+
[
49+
"INITools",
50+
"https://github.com/pypa/pip-test-package/archive/refs/heads/master.zip",
51+
],
52+
ids=["PyPI", "GitHub"],
53+
)
54+
def test_trustore_can_install(
55+
script: PipTestEnvironment,
56+
pip: PipRunner,
57+
package: str,
58+
) -> None:
59+
script.pip("install", "truststore")
60+
result = pip("install", package)
61+
assert "Successfully installed" in result.stdout

0 commit comments

Comments
 (0)