From 0a27d1a125d540fbe00466aee22878f1f35386f1 Mon Sep 17 00:00:00 2001 From: shum Date: Mon, 12 Oct 2026 10:42:23 +0000 Subject: [PATCH] python: pin core libs release with sha-256 --- .../src/simplex_chat/_native.py | 31 ++++++++++++---- .../src/simplex_chat/_version.py | 17 ++++++--- .../tests/test_native_cache.py | 35 +++++++++++++++---- .../tests/test_native_url.py | 6 ++-- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 6918026cd3..009ec685f4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -7,6 +7,7 @@ from __future__ import annotations import ctypes import errno +import hashlib import os import platform import sys @@ -18,7 +19,7 @@ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path from typing import Any, Literal -from ._version import LIBS_VERSION +from ._version import LIBS_RELEASE, LIBS_SHA256 Backend = Literal["sqlite", "postgres"] @@ -59,11 +60,14 @@ def _libname() -> str: return _LIBNAME[sys.platform] -def _libs_url(backend: Backend) -> str: +def _libs_asset(backend: Backend) -> str: suffix = "-postgres" if backend == "postgres" else "" + return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip" + + +def _libs_url(backend: Backend) -> str: return ( - f"https://github.com/{_GITHUB_REPO}/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}" ) @@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path: "postgres backend is only supported on linux-x86_64; " f"current platform is {_platform_tag()}" ) - target = _cache_root() / f"v{LIBS_VERSION}" / backend + target = _cache_root() / LIBS_RELEASE / backend if not (target / _libname()).exists(): _download(target, backend) return target @@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None: what we need and proceed. NEVER rmtree the target: that creates a TOCTOU window where another process is reading/loading the file we're deleting. """ + asset = _libs_asset(backend) + sha256 = LIBS_SHA256.get(asset) + if sha256 is None: + raise RuntimeError(f"no SHA-256 is pinned for {asset}") target.parent.mkdir(parents=True, exist_ok=True) url = _libs_url(backend) print( - f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...", file=sys.stderr, flush=True, ) with tempfile.TemporaryDirectory(dir=target.parent) as tmp: zip_path = Path(tmp) / "libs.zip" _stream_to_file(url, zip_path, timeout=60.0) + _verify_sha256(zip_path, sha256, url) with zipfile.ZipFile(zip_path) as zf: zf.extractall(tmp) # zip layout: /libs/libsimplex.* + libHS*.* @@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None: ) from e +def _verify_sha256(file: Path, expected: str, url: str) -> None: + digest = hashlib.sha256() + with file.open("rb") as f: + while chunk := f.read(_DOWNLOAD_CHUNK): + digest.update(chunk) + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}") + + _lock = threading.Lock() _lib: ctypes.CDLL | None = None _libc: ctypes.CDLL | None = None diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index c6c24a1e59..0786a81795 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,9 +1,16 @@ -"""Single source of truth for both the Python package version and the -simplex-chat-libs release tag we depend on. +"""Package version and the simplex-chat-libs release it loads. -Bump both together for normal releases. For wrapper-only fixes use a PEP 440 -post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +Written by scripts/ci/packages_release.py: the release PR bumps the version +and pins new library releases with the SHA-256 of their assets. """ __version__ = "7.1.0b6" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.6" # simplex-chat-libs release tag (no 'v' prefix) +LIBS_RELEASE = "core-7.1.0.11" # simplex-chat-libs release tag +LIBS_SHA256 = { + "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", + "simplex-chat-libs-linux-x86_64-postgres.zip": "925b3166f969b12082ff8ee659e5f2a9933fe40120deb80c9aa3002a5ba6c803", + "simplex-chat-libs-linux-x86_64.zip": "dabad0824e8813ea4a084fbf37e1ba4cf6dcea9cd8ccadc521fec200f246208a", + "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", + "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", + "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", +} diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 5bd68ddfcd..ec10879077 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -1,3 +1,4 @@ +import hashlib import zipfile from pathlib import Path @@ -5,7 +6,7 @@ import pytest from simplex_chat import _native from simplex_chat._native import _cache_root, _download, _resolve_libs_dir, lib_for -from simplex_chat._version import LIBS_VERSION +from simplex_chat._version import LIBS_RELEASE def test_cache_root_linux(tmp_path, monkeypatch): @@ -43,7 +44,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("simplex_chat._native._download", fake_download) libs_dir = _resolve_libs_dir("sqlite") - assert libs_dir == tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + assert libs_dir == tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" assert called["backend"] == "sqlite" assert (libs_dir / "libsimplex.so").exists() @@ -51,7 +52,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) monkeypatch.setattr("sys.platform", "linux") - cached = tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + cached = tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" cached.mkdir(parents=True) (cached / "libsimplex.so").touch() # Should NOT call _download — use the cached file. @@ -68,9 +69,8 @@ def test_postgres_on_macos_rejected(monkeypatch): _resolve_libs_dir("postgres") -def test_atomic_install(tmp_path, monkeypatch): - """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" - # Build zip: libs/libsimplex.so + libs/libHS-stub.so +def fake_libs_zip(tmp_path, monkeypatch, sha256=None) -> None: + """Serve a zip with libs/libsimplex.so + libs/libHS-stub.so, pinned with `sha256` (its own by default).""" src = tmp_path / "src" / "libs" src.mkdir(parents=True) (src / "libsimplex.so").write_text("fake-so") @@ -85,15 +85,38 @@ def test_atomic_install(tmp_path, monkeypatch): shutil.copy(zip_path, dest) + pinned = sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest() monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + monkeypatch.setattr( + "simplex_chat._native.LIBS_SHA256", {"simplex-chat-libs-linux-x86_64.zip": pinned} + ) + +def test_atomic_install(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) target = tmp_path / "out" _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" +def test_download_rejects_another_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch, sha256="0" * 64) + target = tmp_path / "out" + with pytest.raises(RuntimeError, match=f"expected {'0' * 64}"): + _download(target, "sqlite") + assert sorted(p.name for p in tmp_path.iterdir()) == ["fake-libs.zip", "src"] + + +def test_download_requires_pinned_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) + with pytest.raises( + RuntimeError, match="no SHA-256 is pinned for simplex-chat-libs-linux-x86_64-postgres.zip" + ): + _download(tmp_path / "out", "postgres") + + def test_libc_on_windows_is_ucrt(monkeypatch): loaded: list[str | None] = [] monkeypatch.setattr("sys.platform", "win32") diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index 12270c9db1..f1d60d5170 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from simplex_chat._native import _libname, _libs_url, _platform_tag -from simplex_chat._version import LIBS_VERSION +from simplex_chat._version import LIBS_RELEASE @patch("sys.platform", "linux") @@ -45,7 +45,7 @@ def test_url_sqlite(_): assert ( _libs_url("sqlite") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64.zip" ) @@ -54,5 +54,5 @@ def test_url_postgres(_): assert ( _libs_url("postgres") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64-postgres.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64-postgres.zip" )