Skip to content

Commit

Permalink
🔖 Release 1.1.3 (#7)
Browse files Browse the repository at this point in the history
- 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.
- Ensure `certifi` fallback bundle is loaded even if stored inside a zip-like file.
  • Loading branch information
Ousret authored Oct 9, 2024
1 parent 71ff640 commit 98065df
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 44 deletions.
12 changes: 12 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ jobs:
allow-prereleases: true
- name: Setup dependencies
run: pip install --upgrade pip pytest
- name: Install mkcert (Linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get install mkcert
- name: Install mkcert (MacOS)
if: matrix.os == 'macos-12'
run: brew install mkcert
- name: Inject fake CA in TrustStore
if: matrix.os == 'macos-12' || matrix.os == 'ubuntu-latest'
run: mkcert -install
- name: Generate a valid certificate
if: matrix.os == 'macos-12' || matrix.os == 'ubuntu-latest'
run: mkcert example.test
- name: Build wheels (Unix, Linux)
if: matrix.os != 'windows-latest'
uses: PyO3/maturin-action@v1
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
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).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## 1.1.3 (2024-10-09)

### Changed
- Bumped `rustls-native-certs` to version 0.7.3

### Added
- Automatic (fallback) installation of `certifi` if native trust store access isn't supported on your platform.
- Ensure `certifi` fallback bundle is loaded even if stored inside a zip-like file.

## 1.1.2 (2024-08-17)

### Changed
Expand Down
69 changes: 31 additions & 38 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "wassima"
version = "1.1.2"
version = "1.1.3"
edition = "2021"

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

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

[package.metadata.maturin]
python-source = "wassima"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ around MIT licensed **rustls-native-certs**.
This project allows you to access your original operating system trust store, thus
helping you to verify the remote peer certificates.

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

If your particular operating system is not supported, we will make this happen! Open
Expand Down
3 changes: 3 additions & 0 deletions pyproject.fb.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ classifiers = [
"Development Status :: 5 - Production/Stable"
]
dynamic = ["version"]
dependencies = [
"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')",
]

[tool.hatch.version]
path = "wassima/_version.py"
Expand Down
48 changes: 47 additions & 1 deletion tests/test_ctx.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import http.server
import threading
from os.path import exists
from socket import AF_INET, SOCK_STREAM, socket
from ssl import SSLError
from ssl import PROTOCOL_TLS_SERVER, SSLContext, SSLError
from time import sleep

import pytest

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

s.close()


def serve():
context = SSLContext(PROTOCOL_TLS_SERVER)
context.load_cert_chain(
certfile="./example.test.pem", keyfile="./example.test-key.pem"
)
server_address = ("127.0.0.1", 47476)
httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
httpd.serve_forever()


@pytest.mark.skipif(not exists("./example.test.pem"), reason="test requires mkcert")
def test_ctx_access_local_trusted_root() -> None:
ctx = create_default_ssl_context()

t = threading.Thread(target=serve)
t.daemon = True
t.start()

s = socket(AF_INET, SOCK_STREAM)
s = ctx.wrap_socket(s, server_hostname="example.test")

i = 0

while True:
sleep(1)

if i >= 5:
break

try:
s.connect(("127.0.0.1", 47476))
except ConnectionError:
i += 1
except SSLError:
assert False
else:
break

assert s.getpeercert() is not None
60 changes: 59 additions & 1 deletion wassima/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
import contextlib
import os
import ssl
import tempfile
import typing
from functools import lru_cache
from threading import RLock

if typing.TYPE_CHECKING:
from io import BufferedWriter

from ._version import VERSION, __version__

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


@contextlib.contextmanager
def _atomic_open(filename: str) -> typing.Generator[BufferedWriter, None, None]:
"""Write a file to the disk in an atomic fashion"""
tmp_descriptor, tmp_name = tempfile.mkstemp(dir=os.path.dirname(filename))
try:
with os.fdopen(tmp_descriptor, "wb") as tmp_handler:
yield tmp_handler
os.replace(tmp_name, filename)
except BaseException:
os.remove(tmp_name)
raise


def _extract_zipped_paths(path: str) -> str:
"""Replace nonexistent paths that look like they refer to a member of a zip
archive with the location of an extracted copy of the target, or else
just return the provided path unchanged.
"""
if os.path.exists(path):
# this is already a valid path, no need to do anything further
return path

import zipfile

# find the first valid part of the provided path and treat that as a zip archive
# assume the rest of the path is the name of a member in the archive
archive, member = os.path.split(path)
while archive and not os.path.exists(archive):
archive, prefix = os.path.split(archive)
if not prefix:
# If we don't check for an empty prefix after the split (in other words, archive remains unchanged after the split),
# we _can_ end up in an infinite loop on a rare corner case affecting a small number of users
break
member = "/".join([prefix, member])

if not zipfile.is_zipfile(archive):
return path

zip_file = zipfile.ZipFile(archive)
if member not in zip_file.namelist():
return path

# we have a valid zip archive and a valid member of that archive
tmp = tempfile.gettempdir()
extracted_path = os.path.join(tmp, member.split("/")[-1])

if not os.path.exists(extracted_path):
# use read + write to avoid the creating nested folders, we only want the file, avoids mkdir racing condition
with _atomic_open(extracted_path) as file_handler:
file_handler.write(zip_file.read(member))

return extracted_path


def _split_certifi_bundle(data: bytes) -> list[str]:
line_ending = b"\n" if b"-----\r\n" not in data else b"\r\n"
boundary = b"-----END CERTIFICATE-----" + line_ending
Expand Down Expand Up @@ -66,7 +124,7 @@ def _certifi_fallback() -> list[bytes]:
certs: list[bytes] = []

try:
with open(certifi.where(), "rb") as fp:
with open(_extract_zipped_paths(certifi.where()), "rb") as fp:
for pem_cert in _split_certifi_bundle(fp.read()):
certs.append(ssl.PEM_cert_to_DER_cert(pem_cert))
except (OSError, PermissionError) as e:
Expand Down
Loading

0 comments on commit 98065df

Please sign in to comment.