fix(installer,release): находки agy с живой Windows-машины (A61) #6
3 changed files with 121 additions and 7 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -6,11 +6,22 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
# Матрица из двух систем.
|
||||||
|
#
|
||||||
|
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
|
||||||
|
# держался на Windows, а тесты установки и остановки процессов молча
|
||||||
|
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
|
||||||
|
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
|
||||||
|
# проверяются одинаковым набором.
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Clean Windows Runner Test
|
name: Clean Runner Test (${{ matrix.os }})
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -40,9 +51,13 @@ jobs:
|
||||||
python scripts/release_gate.py
|
python scripts/release_gate.py
|
||||||
|
|
||||||
headless:
|
headless:
|
||||||
name: Headless Run (no GUI dependencies)
|
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import zipfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None:
|
||||||
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None:
|
||||||
|
"""Распаковать архив, не выпуская ни одной записи за пределы dest.
|
||||||
|
|
||||||
|
Отклоняются: абсолютные пути, выход через "..", символические ссылки и
|
||||||
|
любые записи, не являющиеся обычным файлом или каталогом.
|
||||||
|
"""
|
||||||
|
import stat as _stat
|
||||||
|
|
||||||
|
root = dest.resolve()
|
||||||
|
for info in zf.infolist():
|
||||||
|
# Биты типа файла проставлены не всегда: архиватор мог записать только
|
||||||
|
# права доступа. Судим лишь тогда, когда тип действительно указан, —
|
||||||
|
# иначе обычный файл с правами 0o600 выглядел бы записью чужого типа.
|
||||||
|
file_type = _stat.S_IFMT(info.external_attr >> 16)
|
||||||
|
if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR):
|
||||||
|
raise ValueError(
|
||||||
|
f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
name = info.filename.replace("\\", "/")
|
||||||
|
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
||||||
|
raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}")
|
||||||
|
|
||||||
|
target = (root / name).resolve()
|
||||||
|
if target != root and root not in target.parents:
|
||||||
|
raise ValueError(
|
||||||
|
f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if info.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(info, "r") as source, open(target, "wb") as out:
|
||||||
|
shutil.copyfileobj(source, out)
|
||||||
|
|
||||||
|
|
||||||
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
||||||
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
||||||
current_pid = os.getpid()
|
current_pid = os.getpid()
|
||||||
|
|
@ -1212,8 +1250,6 @@ class UpdateManager:
|
||||||
|
|
||||||
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||||
"""Apply update package with automatic backup and rollback on failure."""
|
"""Apply update package with automatic backup and rollback on failure."""
|
||||||
import zipfile
|
|
||||||
|
|
||||||
dest = target_dir or paths.get_repo_root()
|
dest = target_dir or paths.get_repo_root()
|
||||||
backup = self.backup_dir
|
backup = self.backup_dir
|
||||||
backup.mkdir(parents=True, exist_ok=True)
|
backup.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -1229,8 +1265,15 @@ class UpdateManager:
|
||||||
shutil.copytree(src_item, dst_item)
|
shutil.copytree(src_item, dst_item)
|
||||||
|
|
||||||
# 2. Extract update package into dest
|
# 2. Extract update package into dest
|
||||||
|
#
|
||||||
|
# Каждая запись проверяется до записи на диск. Измерено, что
|
||||||
|
# extractall в CPython уже отбрасывает "..", ведущие разделители и
|
||||||
|
# буквы дисков, а запись-ссылку кладёт обычным файлом: побега из
|
||||||
|
# каталога добиться не удалось. Но это свойство реализации, а не
|
||||||
|
# обещание формата — а распаковка идёт в корень установки. Проверка
|
||||||
|
# делает границу собственным инвариантом, который виден в тестах.
|
||||||
with zipfile.ZipFile(package_zip, "r") as zf:
|
with zipfile.ZipFile(package_zip, "r") as zf:
|
||||||
zf.extractall(dest)
|
_extract_within(zf, dest)
|
||||||
|
|
||||||
# 3. Verify syntax and integrity of updated python files in src/
|
# 3. Verify syntax and integrity of updated python files in src/
|
||||||
import py_compile
|
import py_compile
|
||||||
|
|
|
||||||
|
|
@ -556,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel():
|
||||||
assert "setInterval(pollUpdateProgress" in tail, (
|
assert "setInterval(pollUpdateProgress" in tail, (
|
||||||
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ──
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_update_package_cannot_write_outside_target(tmp_path):
|
||||||
|
"""Ни одна запись архива не должна оказаться вне каталога установки.
|
||||||
|
|
||||||
|
Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители
|
||||||
|
и буквы дисков, а запись-ссылку кладёт обычным файлом — побега добиться не
|
||||||
|
удалось, вопреки формулировке аудита. Но это свойство реализации, а не
|
||||||
|
обещание формата, и распаковка идёт в корень установки. Тест закрепляет
|
||||||
|
границу как собственный инвариант.
|
||||||
|
"""
|
||||||
|
import stat as _stat
|
||||||
|
import zipfile as _zipfile
|
||||||
|
from antigravity_provider.updater.update_manager import _extract_within
|
||||||
|
|
||||||
|
dest = tmp_path / "dest"
|
||||||
|
dest.mkdir()
|
||||||
|
outside = tmp_path / "outside.txt"
|
||||||
|
|
||||||
|
hostile = [
|
||||||
|
("выход через ..", "../outside.txt"),
|
||||||
|
("абсолютный путь", "/etc/passwd"),
|
||||||
|
("путь с буквой диска", "C:/Windows/x.txt"),
|
||||||
|
]
|
||||||
|
for index, (label, arcname) in enumerate(hostile):
|
||||||
|
archive = tmp_path / f"hostile_{index}.zip"
|
||||||
|
with _zipfile.ZipFile(archive, "w") as zf:
|
||||||
|
zf.writestr(arcname, "побег")
|
||||||
|
with _zipfile.ZipFile(archive) as zf:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_extract_within(zf, dest)
|
||||||
|
assert not outside.exists(), f"{label}: запись оказалась вне каталога установки"
|
||||||
|
|
||||||
|
# Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом.
|
||||||
|
link_zip = tmp_path / "link.zip"
|
||||||
|
with _zipfile.ZipFile(link_zip, "w") as zf:
|
||||||
|
info = _zipfile.ZipInfo("link")
|
||||||
|
info.external_attr = (_stat.S_IFLNK | 0o777) << 16
|
||||||
|
zf.writestr(info, "/etc/passwd")
|
||||||
|
with _zipfile.ZipFile(link_zip) as zf:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_extract_within(zf, dest)
|
||||||
|
|
||||||
|
# Обычный пакет распаковывается как прежде.
|
||||||
|
good = tmp_path / "good.zip"
|
||||||
|
with _zipfile.ZipFile(good, "w") as zf:
|
||||||
|
zf.writestr("src/module.py", "x = 1\n")
|
||||||
|
zf.writestr("assets/logo.txt", "logo")
|
||||||
|
with _zipfile.ZipFile(good) as zf:
|
||||||
|
_extract_within(zf, dest)
|
||||||
|
assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n"
|
||||||
|
assert (dest / "assets" / "logo.txt").is_file()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue