zapretgui/tests/test_update_core_swap.py
loop-uh 46463e70ea
All checks were successful
Zapret source guards / validate (push) Successful in 10s
Windows-сборка / Dev Dev 21.1.7.168 опубликован
Обновление больше не может «удалить» программу: неудачный откат не стирается
Жалоба: «попыталась обновиться, не вышло и самоудалилась с концами».
Причина в общем ядре: откат, споткнувшийся о занятый файл (антивирус
проверяет только что переименованный exe), очищал журнал целиком — прежний
exe оставался в .update/…/swap/old, повторить откат было нечем, а уборка
через сутки стирала эту папку вместе с последней копией программы.

- rollback/recover: невозвращённое остаётся в журнале, повтор делает
  только его; swap.pending() — уборка такую папку не удаляет.
- run_apply: откат повторяется до 4 раз, процессы из папки останавливаются
  снова; сбой закрытия программы больше не отменяет откат.
- prepare: прошлый недоведённый откат сначала доводится (до сравнения
  файлов), не вышло — новая попытка останавливается, а не стирает old.
- Журнал помнит размер и время файла: устаревший журнал не откатывает
  переустановку.
- commit: замена парами «прежний в old → новый на место» — без exe
  программа одно переименование, а не всю замену.
- ApplyPlan.keep_files: папки поставки, где человек хранит свои файлы,
  лишним не чистятся (нужно Zapret KVN для пресетов).

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

233 lines
8.5 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 os
from pathlib import Path
import pytest
from zapret_update import swap
def _tree(root: Path, files: dict[str, bytes]) -> Path:
for relative, data in files.items():
(root / relative).parent.mkdir(parents=True, exist_ok=True)
(root / relative).write_bytes(data)
return root
def _read(root: Path, skip: str = ".update") -> dict[str, bytes]:
return {
path.relative_to(root).as_posix(): path.read_bytes()
for path in root.rglob("*")
if path.is_file() and not path.relative_to(root).as_posix().startswith(skip)
}
OLD = {"_internal/Zapret.exe": b"old exe", "lua/a.lua": b"a", "lua/gone.lua": b"gone", "user/settings.sqlite3": b"mine"}
NEW = {"_internal/Zapret.exe": b"new exe!", "lua/a.lua": b"a", "lua/added.lua": b"added"}
def _swap(tmp_path, *, remove=("lua/gone.lua",)):
app = _tree(tmp_path / "app", OLD)
stage = _tree(tmp_path / "stage", NEW)
return app, swap.FileSwap(stage, app, app / ".update" / "2" / "swap", preserve=("user",), remove=remove)
def test_commit_replaces_changed_adds_new_removes_owned_and_keeps_user_data(tmp_path) -> None:
app, files = _swap(tmp_path)
files.prepare()
files.commit()
assert _read(app) == {**NEW, "user/settings.sqlite3": b"mine"}
assert (files.unchanged, files.replaced, files.added, files.removed) == (1, 1, 1, 1)
def test_unknown_leftovers_stay_when_the_owner_list_says_nothing(tmp_path) -> None:
app, files = _swap(tmp_path, remove=())
files.prepare()
files.commit()
assert _read(app)["lua/gone.lua"] == b"gone"
def test_rollback_restores_everything(tmp_path) -> None:
app, files = _swap(tmp_path)
files.prepare()
files.commit()
assert files.rollback() == []
assert _read(app) == OLD
def test_swap_interrupted_by_power_loss_is_undone_on_next_start(tmp_path) -> None:
app, files = _swap(tmp_path)
files.prepare()
moves = []
real_move = files._move
def move_then_die(source, destination):
if len(moves) == 2:
raise SystemExit("питание пропало")
moves.append(source)
real_move(source, destination)
files._move = move_then_die
with pytest.raises(SystemExit):
files.commit()
assert _read(app) != OLD # программа наполовину заменена
assert swap.recover(app / ".update" / "2" / "swap") == []
assert _read(app) == OLD
assert swap.recover(app / ".update" / "2" / "swap") is None # журнал убран
def test_new_version_never_overwrites_user_data(tmp_path) -> None:
app = _tree(tmp_path / "app", OLD)
stage = _tree(tmp_path / "stage", {**NEW, "user/settings.sqlite3": b"packaged default"})
files = swap.FileSwap(stage, app, app / ".update" / "x", preserve=("user",))
files.prepare()
files.commit()
assert (app / "user" / "settings.sqlite3").read_bytes() == b"mine"
def test_hardlinked_stage_files_count_as_unchanged(tmp_path) -> None:
app = _tree(tmp_path / "app", {"big.bin": b"x" * 1000})
stage = tmp_path / "stage"
stage.mkdir()
os.link(app / "big.bin", stage / "big.bin")
files = swap.FileSwap(stage, app, app / ".update" / "x")
files.prepare()
assert (files.unchanged, files.replaced) == (1, 0)
def _lock_once(files, path: Path):
"""Первый перенос ``path`` падает «файл занят» (антивирус), дальше — как обычно."""
real_move = files._move
state = {"locked": True}
def move(source, destination):
if state["locked"] and Path(source) == path:
state["locked"] = False
raise PermissionError(13, "занят антивирусом", str(source))
real_move(source, destination)
files._move = move
def test_rollback_that_hit_a_locked_file_keeps_it_in_the_journal(tmp_path) -> None:
app, files = _swap(tmp_path)
files._lock_wait_s = 0.0
files.prepare()
files.commit()
_lock_once(files, app / "_internal" / "Zapret.exe")
errors = files.rollback()
assert errors and "Zapret.exe" in errors[0]
work = app / ".update" / "2" / "swap"
# Прежний exe ещё в old: журнал его помнит, уборка папку не тронет.
assert (work / "old" / "_internal" / "Zapret.exe").read_bytes() == b"old exe"
assert swap.pending(work)
# Повтор доделывает только оставшееся — и программа целиком прежняя.
assert files.rollback() == []
assert _read(app) == OLD
assert not swap.pending(work)
def test_recover_repeats_only_what_failed(tmp_path, monkeypatch) -> None:
app, files = _swap(tmp_path)
files._lock_wait_s = 0.0
files.prepare()
files.commit()
_lock_once(files, app / "_internal" / "Zapret.exe")
assert files.rollback()
work = app / ".update" / "2" / "swap"
# Раньше журнал после такого отката был пуст: recover() ничего не делал,
# а уборка через сутки стирала old с последней копией программы.
assert swap.recover(work) == []
assert _read(app) == OLD
assert swap.recover(work) is None
def test_next_attempt_finishes_the_old_rollback_before_wiping_old(tmp_path) -> None:
app, files = _swap(tmp_path)
files._lock_wait_s = 0.0
files.prepare()
files.commit()
_lock_once(files, app / "_internal" / "Zapret.exe")
assert files.rollback()
again = swap.FileSwap(tmp_path / "stage", app, app / ".update" / "2" / "swap", preserve=("user",), remove=["lua/gone.lua"])
again.prepare()
# Прежний exe не стёрт вместе с old, а возвращён на место до новой попытки.
assert (app / "_internal" / "Zapret.exe").read_bytes() == b"old exe"
again.commit()
assert _read(app) == {**NEW, "user/settings.sqlite3": b"mine"}
def test_next_attempt_stops_when_the_old_rollback_still_cannot_finish(tmp_path, monkeypatch) -> None:
app, files = _swap(tmp_path)
files._lock_wait_s = 0.0
files.prepare()
files.commit()
_lock_once(files, app / "_internal" / "Zapret.exe")
assert files.rollback()
monkeypatch.setattr(swap.FileSwap, "_move", lambda self, s, d: (_ for _ in ()).throw(PermissionError(13, "занят")))
again = swap.FileSwap(tmp_path / "stage", app, app / ".update" / "2" / "swap", preserve=("user",))
with pytest.raises(swap.InstallError, match="не откачено"):
again.prepare()
assert (app / ".update" / "2" / "swap" / "old" / "_internal" / "Zapret.exe").read_bytes() == b"old exe"
def test_old_journal_does_not_undo_a_reinstall(tmp_path) -> None:
app, files = _swap(tmp_path)
files.prepare()
files.commit() # журнал остался: «питание пропало» до уборки
# Человек переустановил программу установщиком поверх.
(app / "_internal" / "Zapret.exe").write_bytes(b"reinstalled exe")
os.utime(app / "_internal" / "Zapret.exe", ns=(1, 1))
swap.recover(app / ".update" / "2" / "swap")
assert (app / "_internal" / "Zapret.exe").read_bytes() == b"reinstalled exe"
def test_program_is_without_its_exe_for_one_rename_only(tmp_path) -> None:
app, files = _swap(tmp_path)
files.prepare()
exe = app / "_internal" / "Zapret.exe"
missing_after = []
real_move = files._move
def move(source, destination):
real_move(source, destination)
missing_after.append(not exe.exists())
files._move = move
files.commit()
# Раньше exe отсутствовал, пока выносились все заменяемые и лишние файлы.
assert sum(missing_after) == 1
def test_own_files_in_a_shipped_folder_survive(tmp_path) -> None:
app = _tree(tmp_path / "app", {**OLD, "presets/Default.txt": b"builtin", "presets/My own.txt": b"mine"})
stage = _tree(tmp_path / "stage", {**NEW, "presets/Default.txt": b"builtin v2"})
files = swap.FileSwap(stage, app, app / ".update" / "x", preserve=("user",), keep=("presets",))
files.prepare()
files.commit()
assert (app / "presets" / "My own.txt").read_bytes() == b"mine"
assert (app / "presets" / "Default.txt").read_bytes() == b"builtin v2"
assert not (app / "lua" / "gone.lua").exists() # остальная поставка убирается как прежде