Skip to content

Commit 2d4ec8f

Browse files
author
Ahmed TAHRI
committed
🔖 Release 1.1.3
- Bumped `rustls-native-certs` to version 0.7.3 - Automatic (fallback) installation of `certifi` if native trust store access isn't supported on your platform.
1 parent 71ff640 commit 2d4ec8f

File tree

9 files changed

+162
-30
lines changed

9 files changed

+162
-30
lines changed

.github/workflows/CI.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ jobs:
4444
allow-prereleases: true
4545
- name: Setup dependencies
4646
run: pip install --upgrade pip pytest
47+
- name: Install mkcert (Linux)
48+
if: matrix.os == 'ubuntu-latest'
49+
run: sudo apt-get install mkcert
50+
- name: Install mkcert (MacOS)
51+
if: matrix.os == 'macos-12'
52+
run: brew install mkcert
53+
- name: Install mkcert (Windows)
54+
if: matrix.os == 'windows-latest'
55+
run: choco install mkcert sudo
56+
- name: Inject fake CA in TrustStore
57+
if: matrix.os == 'windows-latest'
58+
run: sudo mkcert -install
59+
- name: Inject fake CA in TrustStore
60+
if: matrix.os != 'windows-latest'
61+
run: mkcert -install
62+
- name: Generate a valid certificate
63+
run: mkcert example.test
4764
- name: Build wheels (Unix, Linux)
4865
if: matrix.os != 'windows-latest'
4966
uses: PyO3/maturin-action@v1

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
All notable changes to wassima will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

6+
## 1.1.3 (2024-09-30)
7+
8+
### Changed
9+
- Bumped `rustls-native-certs` to version 0.7.3
10+
11+
### Added
12+
- Automatic (fallback) installation of `certifi` if native trust store access isn't supported on your platform.
13+
614
## 1.1.2 (2024-08-17)
715

816
### Changed

Cargo.lock

Lines changed: 24 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wassima"
3-
version = "1.1.2"
3+
version = "1.1.3"
44
edition = "2021"
55

66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -10,7 +10,7 @@ crate-type = ["cdylib"]
1010

1111
[dependencies]
1212
pyo3 = { version = "0.20.3", features = ["abi3-py37", "extension-module"] }
13-
rustls-native-certs = "0.7.1"
13+
rustls-native-certs = "0.7.3"
1414

1515
[package.metadata.maturin]
1616
python-source = "wassima"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ around MIT licensed **rustls-native-certs**.
1919
This project allows you to access your original operating system trust store, thus
2020
helping you to verify the remote peer certificates.
2121

22-
It works as-is out-of-the-box for MacOS, Windows, and Linux.
22+
It works as-is out-of-the-box for MacOS, Windows, and Linux. Automatically fallback on Certifi otherwise.
2323
Available on PyPy and Python 3.7+
2424

2525
If your particular operating system is not supported, we will make this happen! Open

pyproject.fb.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ classifiers = [
3737
"Development Status :: 5 - Production/Stable"
3838
]
3939
dynamic = ["version"]
40+
dependencies = [
41+
"certifi; (platform_python_implementation != 'CPython' or python_full_version < '3.7.10') or (platform_system != 'Darwin' and platform_system != 'Windows' and platform_system != 'Linux') or (platform_machine != 'x86_64' and platform_machine != 's390x' and platform_machine != 'aarch64' and platform_machine != 'armv7l' and platform_machine != 'ppc64le' and platform_machine != 'ppc64' and platform_machine != 'AMD64' and platform_machine != 'arm64' and platform_machine != 'ARM64' and platform_machine != 'i686') or (platform_python_implementation == 'PyPy' and python_version >= '3.11')",
42+
]
4043

4144
[tool.hatch.version]
4245
path = "wassima/_version.py"

tests/test_ctx.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from __future__ import annotations
22

3+
import http.server
4+
import threading
5+
from os.path import exists
36
from socket import AF_INET, SOCK_STREAM, socket
4-
from ssl import SSLError
7+
from ssl import PROTOCOL_TLS_SERVER, SSLContext, SSLError
8+
from time import sleep
59

610
import pytest
711

@@ -59,3 +63,45 @@ def test_ctx_use_system_store(host: str, port: int, expect_failure: bool) -> Non
5963
assert s.getpeercert() is not None
6064

6165
s.close()
66+
67+
68+
def serve():
69+
context = SSLContext(PROTOCOL_TLS_SERVER)
70+
context.load_cert_chain(
71+
certfile="./example.test.pem", keyfile="./example.test-key.pem"
72+
)
73+
server_address = ("127.0.0.1", 47476)
74+
httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)
75+
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
76+
httpd.serve_forever()
77+
78+
79+
@pytest.mark.skipif(not exists("./example.test.pem"), reason="test requires mkcert")
80+
def test_ctx_access_local_trusted_root() -> None:
81+
ctx = create_default_ssl_context()
82+
83+
t = threading.Thread(target=serve)
84+
t.daemon = True
85+
t.start()
86+
87+
s = socket(AF_INET, SOCK_STREAM)
88+
s = ctx.wrap_socket(s, server_hostname="example.test")
89+
90+
i = 0
91+
92+
while True:
93+
sleep(1)
94+
95+
if i >= 5:
96+
break
97+
98+
try:
99+
s.connect(("127.0.0.1", 47476))
100+
except ConnectionError:
101+
i += 1
102+
except SSLError:
103+
assert False
104+
else:
105+
break
106+
107+
assert s.getpeercert() is not None

wassima/__init__.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
import contextlib
88
import os
99
import ssl
10+
import tempfile
1011
import typing
1112
from functools import lru_cache
1213
from threading import RLock
1314

15+
if typing.TYPE_CHECKING:
16+
from io import BufferedWriter
17+
1418
from ._version import VERSION, __version__
1519

1620
#: Determine if we could load correctly the non-native rust module.
@@ -24,6 +28,60 @@
2428
_USER_APPEND_CA_LOCK = RLock()
2529

2630

31+
@contextlib.contextmanager
32+
def _atomic_open(filename: str) -> typing.Generator[BufferedWriter, None, None]:
33+
"""Write a file to the disk in an atomic fashion"""
34+
tmp_descriptor, tmp_name = tempfile.mkstemp(dir=os.path.dirname(filename))
35+
try:
36+
with os.fdopen(tmp_descriptor, "wb") as tmp_handler:
37+
yield tmp_handler
38+
os.replace(tmp_name, filename)
39+
except BaseException:
40+
os.remove(tmp_name)
41+
raise
42+
43+
44+
def _extract_zipped_paths(path: str) -> str:
45+
"""Replace nonexistent paths that look like they refer to a member of a zip
46+
archive with the location of an extracted copy of the target, or else
47+
just return the provided path unchanged.
48+
"""
49+
if os.path.exists(path):
50+
# this is already a valid path, no need to do anything further
51+
return path
52+
53+
import zipfile
54+
55+
# find the first valid part of the provided path and treat that as a zip archive
56+
# assume the rest of the path is the name of a member in the archive
57+
archive, member = os.path.split(path)
58+
while archive and not os.path.exists(archive):
59+
archive, prefix = os.path.split(archive)
60+
if not prefix:
61+
# If we don't check for an empty prefix after the split (in other words, archive remains unchanged after the split),
62+
# we _can_ end up in an infinite loop on a rare corner case affecting a small number of users
63+
break
64+
member = "/".join([prefix, member])
65+
66+
if not zipfile.is_zipfile(archive):
67+
return path
68+
69+
zip_file = zipfile.ZipFile(archive)
70+
if member not in zip_file.namelist():
71+
return path
72+
73+
# we have a valid zip archive and a valid member of that archive
74+
tmp = tempfile.gettempdir()
75+
extracted_path = os.path.join(tmp, member.split("/")[-1])
76+
77+
if not os.path.exists(extracted_path):
78+
# use read + write to avoid the creating nested folders, we only want the file, avoids mkdir racing condition
79+
with _atomic_open(extracted_path) as file_handler:
80+
file_handler.write(zip_file.read(member))
81+
82+
return extracted_path
83+
84+
2785
def _split_certifi_bundle(data: bytes) -> list[str]:
2886
line_ending = b"\n" if b"-----\r\n" not in data else b"\r\n"
2987
boundary = b"-----END CERTIFICATE-----" + line_ending
@@ -66,7 +124,7 @@ def _certifi_fallback() -> list[bytes]:
66124
certs: list[bytes] = []
67125

68126
try:
69-
with open(certifi.where(), "rb") as fp:
127+
with open(_extract_zipped_paths(certifi.where()), "rb") as fp:
70128
for pem_cert in _split_certifi_bundle(fp.read()):
71129
certs.append(ssl.PEM_cert_to_DER_cert(pem_cert))
72130
except (OSError, PermissionError) as e:

wassima/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from __future__ import annotations
22

3-
__version__ = "1.1.2"
3+
__version__ = "1.1.3"
44
VERSION = __version__.split(".")

0 commit comments

Comments
 (0)