zapretgui/tests/e2e_windows/run_engine_e2e.py
loop-uh 917aceab71
All checks were successful
Zapret source guards / validate (push) Successful in 7s
Драйвер WinDivert и запуск winws: служба Monkey больше не зависает — программа сама держала её незакрытым хэндлом (CloseServiceHandle без argtypes падал с «int too long to convert»); новый слой winws_runtime/engine на типизированном WinAPI: запуск подтверждается строкой windivert initialized из вывода winws, остановка — сигналом Windows о выходе процесса, без пауз «на глаз»; кнопка «Стоп», выход и обновление выгружают драйвер и убирают его службу, когда им никто не пользуется (кроме Kaspersky), перезапуск и смена пресета драйвер не трогают; останавливаются только winws из папки программы, чужая копия запрета не убивается; реестр службы, DeleteFlag и тип запуска больше не правятся, штатное «отключена и помечена на удаление» у работающего драйвера не считается поломкой; застрявшая служба называется пользователю с причиной; оркестратор переведён на те же подтверждения; вывод winws первой версии пишется в файл; сквозные проверки на живой Windows в tests/e2e_windows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:34:45 +03:00

420 lines
19 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.

"""Сквозная проверка слоя управления движком на живой Windows.
Запускается на машине с установленной программой, от администратора:
python tests/e2e_windows/run_engine_e2e.py [C:\\Zapret\\Dev]
Проверяет на настоящих winws2.exe и драйвере WinDivert то, что нельзя
проверить подделками: что хэндлы службы не утекают, что остановка
подтверждается сигналом Windows, что перезапуск не требует пауз, что драйвер
выгружается только когда им никто не пользуется, и что оба вида «служба
зависла» распознаются и проходят сами.
Движок запускается с фильтром на TCP-порт 9 (discard): он открывает драйвер
по-настоящему, но чужой трафик не трогает.
Имя файла намеренно не начинается с ``test_``: обычный pytest на Linux его не
собирает. Скрипт сам печатает итог и возвращает ненулевой код при провале.
"""
from __future__ import annotations
import ctypes
import os
import shutil
import subprocess
import sys
import tempfile
import time
import traceback
_HERE = os.path.dirname(os.path.abspath(__file__))
_SRC = os.path.normpath(os.path.join(_HERE, "..", "..", "src"))
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from winws_runtime.engine import driver, process_control, startup, winapi # noqa: E402
CREATE_NO_WINDOW = 0x08000000
ENGINE_ARGS = ("--wf-tcp-out=9", "--wf-dup-check=0")
SERVICE = driver.OWN_DRIVER_SERVICE_NAME
STATUS_DLL_INIT_FAILED = 0xC0000142
DLL_INIT_RETRIES = 2
class Lab:
def __init__(self, root: str):
self.root = os.path.abspath(root)
self.exe = os.path.join(self.root, "exe", "winws2.exe")
self.tmp = tempfile.mkdtemp(prefix="zapret_e2e_")
self.foreign_dir = os.path.join(self.tmp, "foreign", "exe")
self._seq = 0
self.spawned: list[subprocess.Popen] = []
self.dll_init_failures = 0
# --- запуск -------------------------------------------------------------
def spawn(self, exe: str | None = None, args=ENGINE_ARGS):
self._seq += 1
output_path = os.path.join(self.tmp, f"out_{self._seq}.log")
exe_path = exe or self.exe
with open(output_path, "wb") as output:
process = subprocess.Popen(
[exe_path, *args],
cwd=os.path.dirname(os.path.dirname(exe_path)),
stdin=subprocess.DEVNULL,
stdout=output,
stderr=output,
creationflags=CREATE_NO_WINDOW,
)
self.spawned.append(process)
return process, output_path
def start_ready(self, exe: str | None = None):
"""Запускает движок и ждёт подтверждения готовности.
Повторяет запуск при сбое инициализации процесса (0xC0000142) — так
же, как это делает программа. Замер: сбой случается примерно раз на
200 запусков, от пауз не зависит, немедленный повтор проходит.
"""
for attempt in range(DLL_INIT_RETRIES + 1):
process, output_path = self.spawn(exe)
outcome = startup.wait_engine_ready(process, output_path)
if outcome.started:
break
exit_code = process.poll()
if exit_code is not None and (exit_code & 0xFFFFFFFF) == STATUS_DLL_INIT_FAILED:
self.dll_init_failures += 1
if attempt < DLL_INIT_RETRIES:
continue
break
assert outcome.started and outcome.reason == startup.REASON_READY, (
f"движок не запустился: {outcome}; код {process.poll()}; вывод: {self.read(output_path)}"
)
return process, outcome
@staticmethod
def read(path: str) -> str:
try:
with open(path, "rb") as stream:
return stream.read().decode("utf-8", "replace")
except OSError:
return ""
# --- состояние ----------------------------------------------------------
def any_engine_alive(self) -> bool:
return bool(process_control.list_engine_processes(("winws.exe", "winws2.exe")))
def release(self, **kwargs):
return driver.release_driver_if_unused(
own_roots=[self.root],
engine_in_use=self.any_engine_alive,
**kwargs,
)
def preflight(self, wait_seconds: float = 3.0):
return driver.ensure_driver_startable(own_roots=[self.root], wait_seconds=wait_seconds)
def make_foreign_copy(self) -> str:
os.makedirs(self.foreign_dir, exist_ok=True)
source_dir = os.path.dirname(self.exe)
for name in os.listdir(source_dir):
source = os.path.join(source_dir, name)
if os.path.isfile(source):
shutil.copy2(source, os.path.join(self.foreign_dir, name))
return os.path.join(self.foreign_dir, "winws2.exe")
def cleanup(self) -> None:
for process in self.spawned:
try:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
except Exception:
pass
shutil.rmtree(self.tmp, ignore_errors=True)
def _clean_slate(lab: Lab) -> None:
"""Приводит систему к исходному виду: нет своих winws, драйвер выгружен."""
process_control.stop_engine_processes([lab.exe])
for process in lab.spawned:
if process.poll() is None:
process_control.stop_process(process)
result = lab.release()
assert not result.stuck, f"исходное состояние: драйвер застрял: {result}"
# ---------------------------------------------------------------------------
# Сценарии
# ---------------------------------------------------------------------------
def scenario_bindings(lab: Lab) -> None:
"""У каждой функции WinAPI заданы типы; раскладка структур верная."""
for library_name, table in winapi.signature_tables():
library = getattr(winapi, f"_{library_name}")
for name, (restype, argtypes) in table.items():
function = getattr(library, name)
assert function.argtypes is not None, f"{library_name}.{name}: нет argtypes"
assert list(function.argtypes) == list(argtypes), f"{library_name}.{name}: argtypes"
assert function.restype is restype, f"{library_name}.{name}: restype"
# Именно эти две функции закрывают хэндлы; без argtypes 64-битный хэндл
# службы не закрывался и сам держал службу драйвера.
assert list(winapi._advapi32.CloseServiceHandle.argtypes) == [winapi.HANDLE]
assert list(winapi._kernel32.CloseHandle.argtypes) == [winapi.HANDLE]
assert ctypes.sizeof(winapi.SERVICE_STATUS) == 28
assert ctypes.sizeof(winapi.QUERY_SERVICE_CONFIGW) == 64
def scenario_start_is_confirmed_by_engine_output(lab: Lab) -> None:
"""Запуск подтверждается строкой готовности самого движка."""
process, outcome = lab.start_ready()
assert outcome.marker_seen
assert process.poll() is None
info = winapi.query_service(SERVICE)
assert info is not None and info.state == winapi.SERVICE_RUNNING, info
# Штатное состояние работающего драйвера: отключён и помечен на удаление.
assert info.start_type == winapi.SERVICE_DISABLED, info
assert driver.is_own_driver(info.image_path, [lab.root]), info
assert lab.preflight().ok
assert process_control.stop_process(process)
def scenario_stop_is_confirmed_and_marked(lab: Lab) -> None:
"""Остановка подтверждена хэндлом процесса; код завершения — наш."""
process, _ = lab.start_ready()
started = time.perf_counter()
assert process_control.stop_process(process, timeout=5.0)
elapsed = time.perf_counter() - started
assert process.poll() == process_control.ENGINE_STOP_EXIT_CODE, process.poll()
assert elapsed < 1.0, f"остановка заняла {elapsed:.2f} с"
def scenario_failed_start_is_detected(lab: Lab) -> None:
"""Движок, умерший при запуске, распознаётся сразу, без ожидания срока."""
process, output_path = lab.spawn(args=("--wf-raw=this is not a filter", "--wf-dup-check=0"))
started = time.perf_counter()
outcome = startup.wait_engine_ready(process, output_path)
elapsed = time.perf_counter() - started
assert not outcome.started and outcome.reason == startup.REASON_EXITED, outcome
assert elapsed < 3.0, f"провал запуска замечен через {elapsed:.2f} с"
def scenario_restart_needs_no_pauses(lab: Lab) -> None:
"""20 перезапусков подряд: новый запуск сразу после подтверждённого выхода."""
process, _ = lab.start_ready()
for _ in range(20):
assert process_control.stop_process(process)
process, _ = lab.start_ready()
assert process_control.stop_process(process)
def scenario_handoff_keeps_new_engine(lab: Lab) -> None:
"""Быстрая смена пресета: новый движок стартует при живом старом."""
old, _ = lab.start_ready()
new, _ = lab.start_ready()
assert process_control.stop_process(old)
assert not process_control.wait_process_exit(new, 0.3), "новый движок умер после остановки старого"
assert process_control.stop_process(new)
def scenario_only_own_engines_are_stopped(lab: Lab) -> None:
"""Останавливаются только движки из нашей папки; чужой остаётся жив."""
foreign_exe = lab.make_foreign_copy()
own_a, _ = lab.start_ready()
own_b, _ = lab.start_ready()
foreign, _ = lab.start_ready(exe=foreign_exe)
result = process_control.stop_engine_processes([lab.exe])
assert result.ok, result
assert sorted(result.stopped) == sorted([own_a.pid, own_b.pid]), result
assert [record.pid for record in result.foreign] == [foreign.pid], result
assert own_a.poll() is not None and own_b.poll() is not None
assert foreign.poll() is None, "чужой движок был убит"
# Пока жив чужой движок, драйвер занят — выгружать его нельзя.
released = lab.release()
assert released.outcome == driver.RELEASE_SKIPPED_IN_USE, released
info = winapi.query_service(SERVICE)
assert info is not None and info.state == winapi.SERVICE_RUNNING, info
assert process_control.stop_process(foreign)
def scenario_driver_released_only_when_unused(lab: Lab) -> None:
"""Драйвер выгружается, только когда им никто не пользуется."""
process, _ = lab.start_ready()
assert lab.release().outcome == driver.RELEASE_SKIPPED_IN_USE
assert process.poll() is None
assert process_control.stop_process(process)
started = time.perf_counter()
released = lab.release()
elapsed = time.perf_counter() - started
assert released.outcome == driver.RELEASE_RELEASED, released
assert winapi.query_service(SERVICE) is None
assert elapsed < 3.0, f"выгрузка заняла {elapsed:.2f} с"
assert lab.release().outcome == driver.RELEASE_ABSENT
# После выгрузки движок сам ставит драйвер заново.
process, _ = lab.start_ready()
assert process_control.stop_process(process)
def scenario_antivirus_blocks_unload(lab: Lab) -> None:
"""При опасном антивирусе драйвер не выгружается."""
process, _ = lab.start_ready()
assert process_control.stop_process(process)
result = lab.release(antivirus_blocks_unload=lambda: True)
assert result.outcome == driver.RELEASE_SKIPPED_ANTIVIRUS, result
info = winapi.query_service(SERVICE)
assert info is not None and info.state == winapi.SERVICE_RUNNING, info
def scenario_service_queries_do_not_leak_handles(lab: Lab) -> None:
"""Сотни запросов состояния не держат службу: она исчезает после остановки.
Это проверка на исходную поломку: незакрытый хэндл службы в нашем
процессе не давал Windows убрать запись, помеченную на удаление.
"""
process, _ = lab.start_ready()
for _ in range(300):
assert winapi.query_service(SERVICE) is not None
lab.preflight(wait_seconds=0.0)
assert process_control.stop_process(process)
released = lab.release()
assert released.outcome == driver.RELEASE_RELEASED, released
assert winapi.query_service(SERVICE) is None
def scenario_stop_pending_is_named_and_recovers(lab: Lab) -> None:
"""Вид зависания №1: драйвер останавливают при живом движке."""
process, _ = lab.start_ready()
state = winapi.send_service_stop(SERVICE) # так делать нельзя — это и есть поломка
assert state == winapi.SERVICE_STOP_PENDING, state
preflight = lab.preflight(wait_seconds=0.3)
assert not preflight.ok and preflight.blocker == driver.BLOCKER_STOP_PENDING, preflight
assert SERVICE in preflight.message
# Лечение — завершить свои движки: драйвер выгружается сам.
assert process_control.stop_engine_processes([lab.exe]).ok
assert lab.preflight(wait_seconds=3.0).ok
process, _ = lab.start_ready()
assert process_control.stop_process(process)
def scenario_stuck_entry_is_named_and_recovers(lab: Lab) -> None:
"""Вид зависания №2: чужая программа держит хэндл службы."""
process, _ = lab.start_ready()
assert process_control.stop_process(process)
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
advapi32.OpenSCManagerW.restype = ctypes.c_void_p
advapi32.OpenSCManagerW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32]
advapi32.OpenServiceW.restype = ctypes.c_void_p
advapi32.OpenServiceW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_uint32]
advapi32.CloseServiceHandle.argtypes = [ctypes.c_void_p]
scm = advapi32.OpenSCManagerW(None, None, 1)
held = advapi32.OpenServiceW(scm, SERVICE, 4)
assert held, "не удалось открыть хэндл службы для имитации"
try:
released = lab.release(wait_seconds=0.5)
assert released.outcome == driver.RELEASE_STUCK_ENTRY, released
preflight = lab.preflight(wait_seconds=0.3)
assert not preflight.ok and preflight.blocker == driver.BLOCKER_STUCK_ENTRY, preflight
# Именно так выглядит поломка для пользователя: движок не стартует.
failed, output_path = lab.spawn()
outcome = startup.wait_engine_ready(failed, output_path)
assert not outcome.started, outcome
finally:
advapi32.CloseServiceHandle(held)
advapi32.CloseServiceHandle(scm)
# Хэндл закрыт — запись уходит сама, реестр никто не трогал.
assert lab.preflight(wait_seconds=3.0).ok
assert winapi.query_service(SERVICE) is None
process, _ = lab.start_ready()
assert process_control.stop_process(process)
def scenario_plain_leftover_entry_is_removed(lab: Lab) -> None:
"""Остановленная запись без пометки удаления убирается штатным вызовом."""
_clean_slate(lab)
driver_file = os.path.join(lab.root, "exe", f"{SERVICE}64.sys")
created = subprocess.run(
["sc.exe", "create", SERVICE, "type=", "kernel", "start=", "demand", "binPath=", driver_file],
capture_output=True,
text=True,
errors="replace",
)
assert created.returncode == 0, created.stdout + created.stderr
info = winapi.query_service(SERVICE)
assert info is not None and info.state == winapi.SERVICE_STOPPED, info
assert info.start_type == winapi.SERVICE_DEMAND_START, info
# Такая запись запуску не мешает: движок запустит драйвер сам.
assert lab.preflight(wait_seconds=0.0).ok
released = lab.release()
assert released.outcome == driver.RELEASE_RELEASED, released
assert winapi.query_service(SERVICE) is None
SCENARIOS = (
scenario_bindings,
scenario_start_is_confirmed_by_engine_output,
scenario_stop_is_confirmed_and_marked,
scenario_failed_start_is_detected,
scenario_restart_needs_no_pauses,
scenario_handoff_keeps_new_engine,
scenario_only_own_engines_are_stopped,
scenario_driver_released_only_when_unused,
scenario_antivirus_blocks_unload,
scenario_service_queries_do_not_leak_handles,
scenario_stop_pending_is_named_and_recovers,
scenario_stuck_entry_is_named_and_recovers,
scenario_plain_leftover_entry_is_removed,
)
def main(argv: list[str]) -> int:
if os.name != "nt":
print("Сквозная проверка работает только на Windows")
return 2
root = argv[1] if len(argv) > 1 else os.environ.get("ZAPRET_E2E_ROOT", r"C:\Zapret\Dev")
lab = Lab(root)
if not os.path.exists(lab.exe):
print(f"Не найден движок: {lab.exe}")
return 2
failures = 0
try:
for scenario in SCENARIOS:
name = scenario.__name__.removeprefix("scenario_")
started = time.perf_counter()
try:
_clean_slate(lab)
scenario(lab)
print(f"PASS {name} ({time.perf_counter() - started:.2f} s)")
except Exception:
failures += 1
print(f"FAIL {name} ({time.perf_counter() - started:.2f} s)")
traceback.print_exc(file=sys.stdout)
try:
_clean_slate(lab)
except Exception:
failures += 1
print("FAIL final_cleanup")
traceback.print_exc(file=sys.stdout)
finally:
lab.cleanup()
passed = max(0, len(SCENARIOS) - failures)
print(f"\n{passed}/{len(SCENARIOS)} сценариев прошло")
if lab.dll_init_failures:
print(f"Разовых сбоев инициализации процесса (0xC0000142), снятых повтором: {lab.dll_init_failures}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))