HUB-1: зелёный main — граница workspace и UTF-8 в verification #2
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:
|
||||
branches: [ main ]
|
||||
|
||||
# Матрица из двух систем.
|
||||
#
|
||||
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
|
||||
# держался на Windows, а тесты установки и остановки процессов молча
|
||||
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
|
||||
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
|
||||
# проверяются одинаковым набором.
|
||||
jobs:
|
||||
test:
|
||||
name: Clean Windows Runner Test
|
||||
runs-on: windows-latest
|
||||
name: Clean Runner Test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -40,9 +51,13 @@ jobs:
|
|||
python scripts/release_gate.py
|
||||
|
||||
headless:
|
||||
name: Headless Run (no GUI dependencies)
|
||||
runs-on: windows-latest
|
||||
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
|
@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None:
|
|||
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:
|
||||
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
||||
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]:
|
||||
"""Apply update package with automatic backup and rollback on failure."""
|
||||
import zipfile
|
||||
|
||||
dest = target_dir or paths.get_repo_root()
|
||||
backup = self.backup_dir
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -1229,8 +1265,15 @@ class UpdateManager:
|
|||
shutil.copytree(src_item, dst_item)
|
||||
|
||||
# 2. Extract update package into dest
|
||||
#
|
||||
# Каждая запись проверяется до записи на диск. Измерено, что
|
||||
# extractall в CPython уже отбрасывает "..", ведущие разделители и
|
||||
# буквы дисков, а запись-ссылку кладёт обычным файлом: побега из
|
||||
# каталога добиться не удалось. Но это свойство реализации, а не
|
||||
# обещание формата — а распаковка идёт в корень установки. Проверка
|
||||
# делает границу собственным инвариантом, который виден в тестах.
|
||||
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/
|
||||
import py_compile
|
||||
|
|
|
|||
|
|
@ -556,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel():
|
|||
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