fix(packaging): verify the release download against a CA bundle that exists

python-build-standalone's OpenSSL has default trust paths that need not
exist on the host, so urlopen failed with "unable to get local issuer
certificate" while browsers and curl accepted the same chain.  Fall back
to certifi when OpenSSL's default cafile is missing, and name the bundle
in use when verification still fails so the next occurrence is one line
to diagnose.
This commit is contained in:
beefcarver committed 2026-11-15 09:51:01 -05:00
1 parent 512c9a663f
commit 4fd688e673
1 file changed
+43 -1
+43 -1
View File
@@ -33,11 +33,14 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import os
import re import re
import ssl
import subprocess import subprocess
import sys import sys
import tarfile import tarfile
import tempfile import tempfile
import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -193,6 +196,30 @@ def sha256(path: Path) -> str:
return digest.hexdigest() return digest.hexdigest()
def ca_file() -> str | None:
"""The CA bundle to verify with, or None to let OpenSSL use its own default.
`uv python install` fetches a python-build-standalone interpreter whose
OpenSSL is compiled with default trust paths that need not exist on the
host (on macOS, and on distros that keep certs only in
/etc/ssl/certs/ca-certificates.crt, they often do not). Verification then
fails with `unable to get local issuer certificate` for every site, even
though curl and browsers are happy. Fall back to certifi, which `httpx`
already puts in the environment.
"""
if os.environ.get("SSL_CERT_FILE"):
return None # ssl honours the variable itself; do not second-guess it
paths = ssl.get_default_verify_paths()
for candidate in (paths.cafile, paths.openssl_cafile):
if candidate and Path(candidate).is_file():
return None
try:
import certifi
except ModuleNotFoundError:
return None
return certifi.where()
def fetch_asset(version: str, override: Path | None) -> Path: def fetch_asset(version: str, override: Path | None) -> Path:
if override is not None: if override is not None:
if not override.is_file(): if not override.is_file():
@@ -200,10 +227,25 @@ def fetch_asset(version: str, override: Path | None) -> Path:
return override return override
url = RELEASE_ASSET.format(version=version, name=ASSET_NAME) url = RELEASE_ASSET.format(version=version, name=ASSET_NAME)
say(f"downloading {url}") say(f"downloading {url}")
cafile = ca_file()
context = ssl.create_default_context(cafile=cafile)
target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz" target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz"
try: try:
with urllib.request.urlopen(url) as response, target.open("wb") as handle: with urllib.request.urlopen(url, context=context) as response, target.open(
"wb"
) as handle:
handle.write(response.read()) handle.write(response.read())
except urllib.error.URLError as error:
# urlopen wraps socket/SSL failures; the cause is in `.reason`.
if isinstance(error.reason, ssl.SSLCertVerificationError):
bundle = cafile or ssl.get_default_verify_paths().cafile or "no bundle found"
raise fail(
f"cannot fetch the release asset ({error.reason.verify_message or error.reason}); "
f"verified against {bundle}. If that bundle is stale, point SSL_CERT_FILE "
"at a current one (on Arch and Debian that is "
"/etc/ssl/certs/ca-certificates.crt)"
) from error
raise fail(f"cannot fetch the release asset ({error.reason}); is the tag published?") from error
except OSError as error: except OSError as error:
raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error
return target return target