zapretgui/tests/test_updater_signed_release.py
loop-uh d06674c7ee
All checks were successful
Zapret source guards / validate (push) Successful in 9s
Windows-сборка / Dev Dev 21.1.7.156 опубликован
Обновление по файлам: Zapret 2 GUI качает только изменившееся и ставит себя сама
Как у Zapret KVN, общим ядром zapret_update: подписанный список против
файлов на диске, изменившиеся файлы и заплатки из хранилища Forgejo и
зеркал с докачкой, пробное дерево в <корень>/.update/<версия>, запуск
новой версии с --apply-update. Она останавливает WinDivert и Monkey из
своей папки, меняет файлы по журналу, правит версию в «Программах и
компонентах», проверяет свой запуск и при сбое возвращает прежнюю.
Ход установки — в той же записи handoff.json, что у установщика Inno:
окно-продолжение, защита от повторного запуска и отчёт серверу о
неудаче с кодом работают без переделки. Любой сбой до запуска новой
версии — установщик Inno, как раньше. При старте замена, оборванная
выключением, откатывается, старые рабочие папки убираются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:10:13 +03:00

233 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
"""Установщик ставится, только если его подтверждает подписанный список выпуска."""
import base64
import hashlib
import json
import unittest
from unittest.mock import patch
from updater.download.contracts import UpdateSignatureError
from updater.download.flow import resolve_artifact
from updater.release import signed
from updater.release.resolver import ReleaseLookup
from zapret_update import manifest
from zapret_update.ed25519 import public_key_from_seed, sign_ed25519
SEED = hashlib.sha256(b"test key").digest()
OTHER_SEED = hashlib.sha256(b"someone else").digest()
KEYS = {"test-a": public_key_from_seed(SEED)}
def _manifest_bytes(version="99.1.2.3", *, sha="a" * 64, size=12345, channel="dev", key="test-a") -> bytes:
return manifest.dumps(
{
"schema": 1,
"product": "zapret-gui",
"channel": channel,
"version": version,
"created": 1792000000,
"key": key,
"files": [{"path": "_internal/Zapret.exe", "size": 3, "sha256": "b" * 64}],
"installer": {"name": "Zapret2Setup.exe", "size": size, "sha256": sha},
}
)
def _release(version="99.1.2.3", *, with_manifest=True) -> dict:
file_name = f"Zapret2Setup_DEV_{version.replace('.', '_')}.exe"
base = f"https://git2.zapret.moe/zapretdiscordyoutube/zapretgui/releases/download/{version}/"
release = {
"version": version,
"update_url": base + file_name,
"file_name": file_name,
"file_size": 12345,
"sha256": "a" * 64,
"verify_ssl": True,
"source": "Forgejo API",
}
if with_manifest:
release["manifest_url"] = base + manifest.MANIFEST_NAME
release["signature_url"] = base + manifest.SIGNATURE_NAME
return release
class _Response:
def __init__(self, body: bytes) -> None:
self.body = body
def raise_for_status(self) -> None:
pass
def iter_content(self, _size):
yield self.body
def close(self) -> None:
pass
class _Session:
def __init__(self, files: dict[str, bytes]) -> None:
self.files = files
def get(self, url, **_kwargs):
return _Response(self.files[url.rsplit("/", 1)[1]])
def close(self) -> None:
pass
def _served(raw: bytes, seed: bytes = SEED):
signature = base64.b64encode(sign_ed25519(seed, raw)) + b"\n"
files = {manifest.MANIFEST_NAME: raw, manifest.SIGNATURE_NAME: signature}
return lambda: _Session(files)
class SignedReleaseTests(unittest.TestCase):
def _resolve(self, release: dict, session_factory, **kwargs):
real = signed.fetch_signed_release
def fetch(*args, **options):
return real(*args, session_factory=session_factory, **options)
with (
patch.object(signed, "TRUSTED_KEYS", KEYS),
patch("updater.download.flow.fetch_signed_release", fetch),
patch("updater.download.flow.lookup_latest_release", return_value=ReleaseLookup(release)),
patch("updater.download.flow.APP_VERSION", "21.1.5.79"),
patch("updater.download.flow.CHANNEL", "dev"),
):
return resolve_artifact(**kwargs)
def test_signed_installer_is_taken_from_the_list(self) -> None:
artifact = self._resolve(_release(), _served(_manifest_bytes()))
self.assertEqual((artifact.expected_sha256, artifact.expected_size), ("a" * 64, 12345))
def test_mirror_naming_another_checksum_is_refused(self) -> None:
# Зеркало подменило файл и подставило его сумму: подпись этого не знает.
release = _release()
release["sha256"] = "c" * 64
with self.assertRaises(UpdateSignatureError) as caught:
self._resolve(release, _served(_manifest_bytes()))
self.assertEqual(caught.exception.code, "sign")
def test_list_signed_by_a_stranger_is_refused(self) -> None:
with self.assertRaises(UpdateSignatureError) as caught:
self._resolve(_release(), _served(_manifest_bytes(), seed=OTHER_SEED))
self.assertEqual(caught.exception.code, "sign")
def test_changed_list_breaks_the_signature(self) -> None:
raw = _manifest_bytes()
signature = base64.b64encode(sign_ed25519(SEED, raw))
tampered = raw.replace(b'"a', b'"c', 1)
session = lambda: _Session({manifest.MANIFEST_NAME: tampered, manifest.SIGNATURE_NAME: signature}) # noqa: E731
with self.assertRaises(UpdateSignatureError):
self._resolve(_release(), session)
def test_source_without_a_signed_list_is_not_installed(self) -> None:
with self.assertRaises(UpdateSignatureError) as caught:
self._resolve(_release(with_manifest=False), _served(_manifest_bytes()))
self.assertEqual(caught.exception.code, "nosig")
def test_old_signed_release_cannot_pose_as_an_update(self) -> None:
# Подлинный, но старый список под видом новой версии: версия в списке — своя.
with self.assertRaises(UpdateSignatureError) as caught:
self._resolve(_release(), _served(_manifest_bytes(version="21.1.5.70")))
self.assertEqual(caught.exception.code, "stale")
def test_list_of_another_channel_is_refused(self) -> None:
with self.assertRaises(UpdateSignatureError) as caught:
self._resolve(_release(), _served(_manifest_bytes(channel="stable")))
self.assertEqual(caught.exception.code, "product")
def test_repair_accepts_the_installed_version(self) -> None:
artifact = self._resolve(_release("21.1.5.79"), _served(_manifest_bytes("21.1.5.79")), allow_same_version=True)
self.assertEqual(artifact.version, "21.1.5.79")
class SourcesNameTheSignedListTests(unittest.TestCase):
def test_forgejo_release_carries_its_signed_list(self) -> None:
from updater.release import forgejo
tag = "99.1.2.3"
base = f"https://git2.zapret.moe/zapretdiscordyoutube/zapretgui/releases/download/{tag}/"
names = ["Zapret2Setup_DEV_99_1_2_3.exe", "Zapret2Setup_DEV_99_1_2_3.exe.sha256", manifest.MANIFEST_NAME, manifest.SIGNATURE_NAME]
release = {
"tag_name": tag,
"prerelease": True,
"assets": [{"name": name, "browser_download_url": base + name, "size": 1} for name in names],
}
candidate = forgejo._candidate(release, "dev")
self.assertEqual(candidate["manifest_url"], base + manifest.MANIFEST_NAME)
self.assertEqual(candidate["signature_url"], base + manifest.SIGNATURE_NAME)
def test_mirror_names_the_list_next_to_the_installer(self) -> None:
from updater.release import mirrors
entry = {"version": "99.1.2.3", "file_name": "Zapret2Setup_DEV_99_1_2_3.exe", "file_size": 5, "sha256": "a" * 64}
plain = mirrors.release_from_versions({"dev": dict(entry)}, "dev", base_url="https://m:888", verify_ssl=False, source="m")
signed_entry = mirrors.release_from_versions(
{"dev": {**entry, "manifest": True}}, "dev", base_url="https://m:888", verify_ssl=False, source="m"
)
self.assertNotIn("manifest_url", plain)
self.assertEqual(
signed_entry["manifest_url"], "https://m:888/download/Zapret2Setup_DEV_99_1_2_3.exe." + manifest.MANIFEST_NAME
)
class ManifestFormatTests(unittest.TestCase):
def test_paths_cannot_leave_the_program_folder(self) -> None:
for bad in ("../evil.exe", "/abs.exe", "C:/x.exe", "a\\b.exe", "a//b", "./a"):
payload = json.loads(_manifest_bytes())
payload["files"] = [{"path": bad, "size": 1, "sha256": "b" * 64}]
with self.assertRaises(manifest.ManifestError, msg=bad):
manifest.parse(payload)
def test_build_and_sign_round_trip(self) -> None:
import tempfile
from pathlib import Path
from zapret_update import sign
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "tree" / "_internal").mkdir(parents=True)
(root / "tree" / "_internal" / "Zapret.exe").write_bytes(b"exe")
(root / "tree" / "user").mkdir()
(root / "tree" / "user" / "settings.sqlite3").write_bytes(b"mine")
(root / "setup.exe").write_bytes(b"installer")
key = root / "k.key"
public = base64.b64decode(sign.generate_key("test-b", key))
raw_path, sig_path = sign.make(
key_path=key, product="zapret-gui", channel="dev", version="1.2.3.4",
tree=root / "tree", installer=root / "setup.exe", exclude=("user",), out_dir=root / "out",
)
loaded = manifest.load_signed(
raw_path.read_bytes(), sig_path.read_text(), trusted={"test-b": public},
product="zapret-gui", channel="dev", newer_than="1.2.3.3",
)
self.assertEqual([entry.path for entry in loaded.files], ["_internal/Zapret.exe"])
self.assertEqual(loaded.installer.sha256, hashlib.sha256(b"installer").hexdigest())
def test_rfc8032_vector(self) -> None:
from zapret_update.ed25519 import verify_ed25519_signature
seed = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
signature = sign_ed25519(seed, b"")
self.assertEqual(public_key_from_seed(seed).hex(), "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
self.assertTrue(signature.hex().startswith("e5564300c360ac729086e2cc806e828a"))
self.assertTrue(verify_ed25519_signature(public_key_from_seed(seed), signature, b""))
if __name__ == "__main__":
unittest.main()