mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-30 19:51:59 +01:00
fix(plugins): harden provisioning, runtime, IPC and native upgrades
This commit is contained in:
1 parent
c32fb83626
commit
06c41bccba
30 files changed
+2294
-370
No files matched your search
@@ -0,0 +1,309 @@
|
||||
"""Regression tests for untrusted archives and plugin process lifetime."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from repeater.plugins import manifest as manifest_module
|
||||
from repeater.plugins import runtime as runtime_module
|
||||
from repeater.plugins import storage as storage_module
|
||||
from repeater.plugins.manifest import ManifestError, load_manifest_from_wheel, parse_manifest
|
||||
from repeater.plugins.runtime import PluginRuntime
|
||||
from repeater.plugins.storage import PluginStorage
|
||||
|
||||
|
||||
def manifest_data(entry="ui/index.html"):
|
||||
return {
|
||||
"schema": 1,
|
||||
"id": "openhop.demo",
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"ui": {"type": "application", "entry": entry},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry",
|
||||
[
|
||||
"index.html",
|
||||
"venv/index.html",
|
||||
"venv/bin/python",
|
||||
".venv/index.html",
|
||||
"./index.html",
|
||||
"ui/./index.html",
|
||||
],
|
||||
)
|
||||
def test_ui_requires_dedicated_non_reserved_subtree(entry):
|
||||
with pytest.raises(ManifestError):
|
||||
parse_manifest(manifest_data(entry))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["directory", "leaf", "config"])
|
||||
def test_extract_rejects_existing_symlinks(tmp_path, kind):
|
||||
release = tmp_path / "release"
|
||||
release.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
victim = outside / "index.html"
|
||||
victim.write_text("untouched")
|
||||
wheel = tmp_path / "demo.whl"
|
||||
with zipfile.ZipFile(wheel, "w") as zf:
|
||||
zf.writestr("ui/index.html", "changed")
|
||||
zf.writestr("config.default.json", "changed")
|
||||
if kind == "directory":
|
||||
(release / "ui").symlink_to(outside, target_is_directory=True)
|
||||
elif kind == "leaf":
|
||||
(release / "ui").mkdir()
|
||||
(release / "ui/index.html").symlink_to(victim)
|
||||
else:
|
||||
(release / "config.default.json").symlink_to(victim)
|
||||
runtime = PluginRuntime(PluginStorage(tmp_path / "plugins"))
|
||||
with pytest.raises((ValueError, OSError)):
|
||||
if kind == "config":
|
||||
runtime._extract_config_default_file(wheel, release)
|
||||
else:
|
||||
runtime._extract_ui_assets(wheel, release, parse_manifest(manifest_data()))
|
||||
assert victim.read_text() == "untouched"
|
||||
|
||||
|
||||
def test_ui_matches_path_components_not_substrings(tmp_path):
|
||||
wheel = tmp_path / "demo.whl"
|
||||
release = tmp_path / "release"
|
||||
release.mkdir()
|
||||
with zipfile.ZipFile(wheel, "w") as zf:
|
||||
zf.writestr("pkg/ui/index.html", "good")
|
||||
zf.writestr("notui/secret.txt", "wrong")
|
||||
PluginRuntime(PluginStorage(tmp_path))._extract_ui_assets(
|
||||
wheel, release, parse_manifest(manifest_data())
|
||||
)
|
||||
assert (release / "ui/index.html").read_text() == "good"
|
||||
assert not (release / "ui/secret.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["manifest", "config", "metadata", "ui", "total", "count"])
|
||||
def test_expanded_archive_limits(tmp_path, monkeypatch, kind):
|
||||
monkeypatch.setattr(manifest_module, "ZIP_METADATA_MAX_BYTES", 128, raising=False)
|
||||
monkeypatch.setattr(manifest_module, "ZIP_MEMBER_MAX_BYTES", 128, raising=False)
|
||||
monkeypatch.setattr(manifest_module, "ZIP_TOTAL_MAX_BYTES", 200, raising=False)
|
||||
monkeypatch.setattr(manifest_module, "ZIP_MAX_MEMBERS", 3, raising=False)
|
||||
wheel = tmp_path / "upload.whl"
|
||||
members = {
|
||||
"manifest": {"openhop-plugin.json": json.dumps(manifest_data()) + " " * 256},
|
||||
"config": {"config.default.json": " " * 256},
|
||||
"metadata": {"demo.dist-info/METADATA": " " * 256},
|
||||
"ui": {"ui/index.html": "x" * 256},
|
||||
"total": {"ui/a": "x" * 110, "ui/b": "x" * 110},
|
||||
"count": {f"ui/{i}": "x" for i in range(4)},
|
||||
}[kind]
|
||||
with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, content in members.items():
|
||||
zf.writestr(name, content)
|
||||
release = tmp_path / "release"
|
||||
release.mkdir()
|
||||
runtime = PluginRuntime(PluginStorage(tmp_path / "plugins"))
|
||||
with pytest.raises(ValueError, match="limit|exceeds"):
|
||||
if kind == "manifest":
|
||||
load_manifest_from_wheel(wheel)
|
||||
elif kind == "config":
|
||||
runtime._extract_config_default_file(wheel, release)
|
||||
elif kind == "metadata":
|
||||
runtime._ensure_pip_wheel_filename(wheel)
|
||||
else:
|
||||
runtime._extract_ui_assets(wheel, release, parse_manifest(manifest_data()))
|
||||
|
||||
|
||||
def prepared_runtime(tmp_path, **kwargs):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
paths = storage.ensure_plugin_layout("openhop.demo", "1.0.0")
|
||||
data = manifest_data()
|
||||
data["runtime"] = {"type": "python", "entrypoint": "demo-cli"}
|
||||
manifest = parse_manifest(data)
|
||||
storage.write_manifest(manifest.id, manifest.version, manifest)
|
||||
storage.write_state(manifest.id, {"version": manifest.version, "enabled": True})
|
||||
storage.set_current(manifest.id, manifest.version)
|
||||
return PluginRuntime(storage, **kwargs), paths
|
||||
|
||||
|
||||
def test_failed_rebuild_restores_old_environment_and_retries(tmp_path):
|
||||
calls = []
|
||||
fail = True
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if "venv" in cmd:
|
||||
target = Path(cmd[-1])
|
||||
(target / "bin").mkdir(parents=True)
|
||||
(target / "bin/python").symlink_to(sys.executable)
|
||||
(target / "pyvenv.cfg").write_text(
|
||||
f"version = {sys.version_info.major}.{sys.version_info.minor}.0\n"
|
||||
)
|
||||
else:
|
||||
if fail:
|
||||
raise RuntimeError("pip failed")
|
||||
script = Path(cmd[0]).parent / "demo-cli"
|
||||
script.write_text(f"#!{cmd[0]}\nprint('working')\n")
|
||||
script.chmod(0o755)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
runtime, paths = prepared_runtime(tmp_path, run_factory=run)
|
||||
venv = paths.venv_dir("1.0.0")
|
||||
(venv / "bin").mkdir(parents=True)
|
||||
(venv / "bin/python").write_text("old")
|
||||
(venv / "pyvenv.cfg").write_text("version = 2.7.0\n")
|
||||
(paths.release_dir("1.0.0") / "demo-1.0.0-py3-none-any.whl").touch()
|
||||
with pytest.raises(RuntimeError, match="pip failed"):
|
||||
runtime.ensure_venv_compatible("openhop.demo")
|
||||
assert (venv / "bin/python").read_text() == "old"
|
||||
assert (venv / "pyvenv.cfg").read_text() == "version = 2.7.0\n"
|
||||
fail = False
|
||||
runtime.ensure_venv_compatible("openhop.demo")
|
||||
entry = runtime.resolve_entrypoint(paths, "1.0.0", "demo-cli")
|
||||
assert subprocess.check_output([str(entry)], text=True, timeout=5).strip() == "working"
|
||||
assert len([cmd for cmd in calls if "pip" in cmd]) == 2
|
||||
|
||||
|
||||
def test_install_subprocesses_have_deadlines(tmp_path):
|
||||
calls = []
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
runtime = PluginRuntime(PluginStorage(tmp_path), run_factory=run)
|
||||
runtime._create_venv(tmp_path / "venv")
|
||||
(tmp_path / "venv/bin").mkdir(parents=True)
|
||||
(tmp_path / "venv/bin/python").touch()
|
||||
runtime._pip_install(tmp_path / "venv", tmp_path / "demo-1.0.0-py3-none-any.whl")
|
||||
assert all(0 < call.get("timeout", 0) <= 300 for call in calls)
|
||||
|
||||
|
||||
def test_log_tail_reads_only_bounded_suffix(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage_module, "LOG_TAIL_MAX_BYTES", 128, raising=False)
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
paths = storage.ensure_plugin_layout("openhop.demo", "1.0.0")
|
||||
paths.log_file.write_text("a" * 1024 + "\nlast line\n")
|
||||
result = storage.tail_log("openhop.demo")
|
||||
assert result[-1] == "last line"
|
||||
assert len("\n".join(result).encode()) <= 128
|
||||
|
||||
|
||||
def test_running_plugin_log_is_continuously_bounded(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(runtime_module, "LOG_MAX_BYTES", 1024)
|
||||
runtime, paths = prepared_runtime(tmp_path)
|
||||
bindir = paths.venv_dir("1.0.0") / "bin"
|
||||
bindir.mkdir(parents=True)
|
||||
entry = bindir / "demo-cli"
|
||||
marker = tmp_path / "ready"
|
||||
entry.write_text(
|
||||
f"#!{sys.executable}\nimport os,time\nfrom pathlib import Path\nos.write(1, b'x' * 32768)\nPath({str(marker)!r}).touch()\ntime.sleep(60)\n"
|
||||
)
|
||||
entry.chmod(0o755)
|
||||
try:
|
||||
runtime.start("openhop.demo")
|
||||
deadline = time.monotonic() + 5
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert marker.exists()
|
||||
time.sleep(0.1)
|
||||
assert paths.log_file.stat().st_size <= 1024
|
||||
finally:
|
||||
runtime.stop("openhop.demo")
|
||||
|
||||
|
||||
def test_install_output_is_bounded(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(runtime_module, "INSTALL_OUTPUT_MAX_BYTES", 128, raising=False)
|
||||
runtime = PluginRuntime(PluginStorage(tmp_path))
|
||||
result = runtime._run(
|
||||
[sys.executable, "-c", "print('x' * 8192)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
assert len(result.stdout.encode()) <= 128
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Linux process-group regression")
|
||||
def test_install_timeout_kills_child_processes(tmp_path):
|
||||
marker = tmp_path / "child.pid"
|
||||
code = (
|
||||
"import subprocess,time; from pathlib import Path; "
|
||||
f"p=subprocess.Popen([{sys.executable!r}, '-c', 'import time; time.sleep(60)']); "
|
||||
f"Path({str(marker)!r}).write_text(str(p.pid)); time.sleep(60)"
|
||||
)
|
||||
runtime = PluginRuntime(PluginStorage(tmp_path))
|
||||
pid = None
|
||||
try:
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
runtime._run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=0.3,
|
||||
)
|
||||
assert marker.exists()
|
||||
pid = int(marker.read_text())
|
||||
deadline = time.monotonic() + 2
|
||||
while alive(pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert not alive(pid)
|
||||
finally:
|
||||
if marker.exists():
|
||||
pid = int(marker.read_text())
|
||||
if pid and alive(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
|
||||
|
||||
def alive(pid):
|
||||
try:
|
||||
return Path(f"/proc/{pid}/stat").read_text().split()[2] != "Z"
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Linux process-group regression")
|
||||
@pytest.mark.parametrize("parent_exits", [False, True])
|
||||
def test_stop_kills_term_ignoring_descendant_even_after_parent_exit(tmp_path, parent_exits):
|
||||
runtime, paths = prepared_runtime(tmp_path, stop_timeout=0.15)
|
||||
venv = paths.venv_dir("1.0.0")
|
||||
(venv / "bin").mkdir(parents=True)
|
||||
entry = venv / "bin/demo-cli"
|
||||
marker = tmp_path / "child.pid"
|
||||
child = "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)"
|
||||
entry.write_text(
|
||||
f"#!{sys.executable}\nimport subprocess,time\nfrom pathlib import Path\np = subprocess.Popen([{sys.executable!r}, '-c', {child!r}])\nPath({str(marker)!r}).write_text(str(p.pid))\ntime.sleep({0.1 if parent_exits else 60})\n"
|
||||
)
|
||||
entry.chmod(0o755)
|
||||
pid = None
|
||||
proc = None
|
||||
try:
|
||||
runtime.start("openhop.demo")
|
||||
proc = runtime._handles["openhop.demo"].process
|
||||
deadline = time.monotonic() + 5
|
||||
while not marker.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert marker.exists()
|
||||
pid = int(marker.read_text())
|
||||
time.sleep(0.15)
|
||||
if parent_exits:
|
||||
proc.wait(timeout=5)
|
||||
runtime.stop("openhop.demo")
|
||||
deadline = time.monotonic() + 2
|
||||
while alive(pid) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert not alive(pid)
|
||||
finally:
|
||||
if pid and alive(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
runtime.stop("openhop.demo")
|
||||
Reference in new issue
Block a user