zapretgui/tests/test_update_core_fetch.py
loop-uh ddf2a93c74
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Общее ядро обновления: скачивание по файлам, пробное дерево, замена с откатом
plan сравнивает подписанный список с диском (суммы запоминаются по
размеру и времени), fetch качает файлы и заплатки из хранилища с
докачкой по Range с любого источника и запасными серверами, stage
собирает пробное дерево новой версии жёсткими ссылками. swap и apply —
установщик Zapret KVN, обобщённый для обеих программ: журнал замены на
диске (recover откатывает замену, оборванную выключением), убираются
только свои файлы прежней версии, папки данных человека не трогаются,
особые шаги программы — в Hooks.

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

190 lines
7.9 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
"""Общее ядро: план, скачивание из хранилища с докачкой и пробное дерево (Python 3.14)."""
import hashlib
import http.server
import random
import threading
from pathlib import Path
import pytest
pytest.importorskip("compression.zstd", reason="сжатие zstd есть только в Python 3.14")
from zapret_update import builder, fetch, manifest, plan, stage, store # noqa: E402
class _Store(http.server.ThreadingHTTPServer):
def __init__(self, files: dict[str, bytes], *, cut_after: int | None = None, corrupt: bool = False) -> None:
self.files, self.cut_after, self.corrupt, self.requests = files, cut_after, corrupt, []
super().__init__(("127.0.0.1", 0), _Handler)
threading.Thread(target=self.serve_forever, daemon=True).start()
def url_for(self, name: str) -> str:
return f"http://127.0.0.1:{self.server_address[1]}/{name}"
class _Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_args) -> None:
pass
def do_GET(self) -> None: # noqa: N802
server: _Store = self.server
name = self.path.lstrip("/")
server.requests.append((name, self.headers.get("Range")))
data = server.files.get(name)
if data is None:
self.send_response(404)
self.end_headers()
return
if server.corrupt:
data = bytes(len(data))
start = 0
if self.headers.get("Range"):
start = int(self.headers["Range"].split("=")[1].rstrip("-"))
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{len(data) - 1}/{len(data)}")
else:
self.send_response(200)
body = data[start:]
if server.cut_after is not None:
body = body[: server.cut_after]
server.cut_after = None # обрыв только один раз
self.send_header("Content-Length", str(len(data) - start))
self.end_headers()
try:
self.wfile.write(body)
except OSError:
pass
def _release(tmp_path: Path):
"""Установленная версия 1 и выпуск 2: один файл изменился, один новый, один ушёл."""
rng = random.Random(7)
exe_old = rng.randbytes(600_000)
exe_new = exe_old[:300_000] + b"fix" * 50 + exe_old[300_000:]
v1 = {"_internal/Zapret.exe": exe_old, "lua/a.lua": b"print(1)", "lua/old.lua": b"old"}
v2 = {"_internal/Zapret.exe": exe_new, "lua/a.lua": b"print(1)", "lua/new.lua": b"new"}
root = tmp_path / "app"
builds = {}
for version, files in (("1.0.0.1", v1), ("1.0.0.2", v2)):
source = tmp_path / f"src{version}"
for relative, data in files.items():
(source / relative).parent.mkdir(parents=True, exist_ok=True)
(source / relative).write_bytes(data)
previous = [builds["1.0.0.1"]] if builds else []
built = builder.build_store(
layout={relative: source / relative for relative in files}, cache_dir=tmp_path / "cache", previous=previous
)
builds[version] = manifest.parse({
"schema": 1, "product": "zapret-gui", "channel": "dev", "version": version, "created": 1, "key": "k",
"files": built.files, "patches": built.patches,
})
for relative, data in v1.items():
(root / relative).parent.mkdir(parents=True, exist_ok=True)
(root / relative).write_bytes(data)
stored = {path.name: path.read_bytes() for path in (tmp_path / "cache").glob("*.zst")}
return root, builds["1.0.0.1"], builds["1.0.0.2"], stored, v2
def test_plan_keeps_same_files_patches_changed_and_removes_gone(tmp_path) -> None:
root, old, new, _stored, _v2 = _release(tmp_path)
planned = plan.make_plan(new, root, installed=old)
assert [entry.path for entry in planned.keep] == ["lua/a.lua"]
by_path = {want.entry.path: want for want in planned.want}
assert by_path["_internal/Zapret.exe"].patch is not None
assert by_path["lua/new.lua"].patch is None
assert planned.remove == ["lua/old.lua"]
assert planned.download_size < 20_000 # вместо ~600 КБ файла целиком
def test_plan_without_knowing_the_installed_version_removes_nothing(tmp_path) -> None:
root, _old, new, _stored, _v2 = _release(tmp_path)
assert plan.make_plan(new, root).remove == []
def test_version_without_store_cannot_be_planned_by_files(tmp_path) -> None:
root, _old, new, _stored, _v2 = _release(tmp_path)
bare = manifest.parse({
"schema": 1, "product": "zapret-gui", "channel": "dev", "version": "1.0.0.3", "created": 1, "key": "k",
"files": [{"path": "a", "size": 1, "sha256": "a" * 64}],
})
with pytest.raises(plan.PlanError):
plan.make_plan(bare, root)
def test_fetch_resumes_after_a_cut_and_builds_the_new_tree(tmp_path) -> None:
root, old, new, stored, v2 = _release(tmp_path)
server = _Store(stored, cut_after=3)
planned = plan.make_plan(new, root, installed=old)
seen = []
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work", threads=2,
sources=[fetch.Source("forgejo", server.url_for)], progress=lambda done, total: seen.append((done, total)),
sleep=lambda _s: None,
)
tree = stage.build_stage(planned, root=root, ready=ready, stage_dir=tmp_path / "stage")
assert {path.relative_to(tree).as_posix(): path.read_bytes() for path in tree.rglob("*") if path.is_file()} == v2
# Оборванный кусок продолжен с места обрыва, а не заново.
assert any(rng and rng != "bytes=0-" for _name, rng in server.requests)
assert seen[-1] == (planned.download_size, planned.download_size)
server.shutdown()
def test_broken_source_is_skipped_for_the_next_one(tmp_path) -> None:
root, old, new, stored, v2 = _release(tmp_path)
bad, good = _Store(stored, corrupt=True), _Store(stored)
planned = plan.make_plan(new, root, installed=old)
ready = fetch.fetch_plan(
planned, root=root, work_dir=tmp_path / "work",
sources=[fetch.Source("bad", bad.url_for), fetch.Source("good", good.url_for)], sleep=lambda _s: None,
)
assert all(hashlib.sha256(path.read_bytes()).hexdigest() == sha for sha, path in ready.items())
bad.shutdown(), good.shutdown()
def test_nothing_anywhere_gives_a_network_reason(tmp_path) -> None:
root, old, new, _stored, _v2 = _release(tmp_path)
empty = _Store({})
planned = plan.make_plan(new, root, installed=old)
with pytest.raises(fetch.FetchError) as caught:
fetch.fetch_plan(planned, root=root, work_dir=tmp_path / "work", sources=[fetch.Source("e", empty.url_for)], sleep=lambda _s: None)
assert caught.value.code == "net"
empty.shutdown()
def test_already_downloaded_files_are_not_fetched_again(tmp_path) -> None:
root, old, new, stored, _v2 = _release(tmp_path)
server = _Store(stored)
planned = plan.make_plan(new, root, installed=old)
kwargs = dict(root=root, work_dir=tmp_path / "work", sources=[fetch.Source("s", server.url_for)], sleep=lambda _s: None)
fetch.fetch_plan(planned, **kwargs)
server.requests.clear()
fetch.fetch_plan(planned, **kwargs)
assert server.requests == []
server.shutdown()
def test_stage_refuses_a_program_file_changed_during_the_update(tmp_path) -> None:
root, old, new, stored, _v2 = _release(tmp_path)
server = _Store(stored)
planned = plan.make_plan(new, root, installed=old)
ready = fetch.fetch_plan(planned, root=root, work_dir=tmp_path / "w", sources=[fetch.Source("s", server.url_for)], sleep=lambda _s: None)
(root / "lua" / "a.lua").write_bytes(b"print(2)") # тот же размер, другое содержимое
with pytest.raises(stage.StageError):
stage.build_stage(planned, root=root, ready=ready, stage_dir=tmp_path / "stage")
server.shutdown()