"""Plugin installs fetch only what the catalog names, verify it, and unpack it only inside the plugin's own folder.""" import hashlib import io import zipfile from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from steward.core import plugin_manager as pm def make_zip(files: dict[str, str]) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: for name, text in files.items(): zf.writestr(name, text) return buf.getvalue() def sha(content: bytes) -> str: return hashlib.sha256(content).hexdigest() @pytest.mark.parametrize("name", ["..", "a/b", "evil-name", "", "x.y", "ümlaut"]) def test_name_must_be_an_identifier(name): with pytest.raises(pm.PluginInstallError): pm.check_plugin_name(name) def test_checksum_is_required_and_compared(): content = b"zip" with pytest.raises(pm.PluginInstallError, match="no checksum"): pm.verify_checksum(content, "") with pytest.raises(pm.PluginInstallError, match="mismatch"): pm.verify_checksum(content, "0" * 64) pm.verify_checksum(content, sha(content).upper()) def test_extract_strips_the_plugin_folder(tmp_path): content = make_zip({"demo-v1.2/__init__.py": "x = 1", "demo-v1.2/sub/a.txt": "a"}) pm.extract_plugin(content, "demo", tmp_path / "demo") assert (tmp_path / "demo" / "__init__.py").read_text() == "x = 1" assert (tmp_path / "demo" / "sub" / "a.txt").read_text() == "a" @pytest.mark.parametrize("member", ["../escape.py", "demo/../../escape.py", "/abs.py"]) def test_extract_refuses_members_outside_the_folder(tmp_path, member): content = make_zip({"__init__.py": "", member: "boom"}) with pytest.raises(pm.PluginInstallError, match="outside"): pm.extract_plugin(content, "demo", tmp_path / "plugins" / "demo") assert not (tmp_path / "escape.py").exists() assert not (tmp_path / "plugins" / "escape.py").exists() assert not (tmp_path / "plugins" / "demo").exists() # nothing half-written def test_extract_refuses_an_oversized_unpack(tmp_path, monkeypatch): monkeypatch.setattr(pm, "MAX_PLUGIN_UNPACKED_BYTES", 10) with pytest.raises(pm.PluginInstallError, match="Unpacks to more than"): pm.extract_plugin(make_zip({"__init__.py": "x" * 11}), "demo", tmp_path / "demo") def _app(tmp_path): return SimpleNamespace(config={"PLUGIN_INSTALL_DIR": str(tmp_path), "PLUGIN_DIRS": [str(tmp_path)], "DATABASE_URL": ""}) @pytest.mark.asyncio async def test_update_replaces_the_folder_whole(tmp_path): old = tmp_path / "demo" old.mkdir() (old / "gone.py").write_text("old") content = make_zip({"demo/__init__.py": "new"}) with patch.object(pm, "_download", new=AsyncMock(return_value=content)): ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", sha(content)) assert ok, msg assert (old / "__init__.py").read_text() == "new" assert not (old / "gone.py").exists() assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"] @pytest.mark.asyncio async def test_a_refused_download_leaves_the_installed_copy(tmp_path): old = tmp_path / "demo" old.mkdir() (old / "__init__.py").write_text("old") content = make_zip({"demo/__init__.py": "new"}) with patch.object(pm, "_download", new=AsyncMock(return_value=content)): ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", "") assert not ok and "no checksum" in msg assert (old / "__init__.py").read_text() == "old" assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"] @pytest.mark.asyncio async def test_route_takes_url_and_checksum_from_the_catalog(client): async with client.session_transaction() as sess: sess.update(user_id="u", user_role="admin", username="admin", _csrf="t") entry = SimpleNamespace(name="demo", version="1.0", download_url="https://catalog/demo.zip", checksum_sha256="abc") install = AsyncMock(return_value=(False, "stopped here")) with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \ patch("steward.settings.routes._get_plugin_repos", return_value=[{"url": "https://catalog/"}]), \ patch("steward.core.plugin_index.fetch_catalog", new=AsyncMock(return_value=[entry])), \ patch("steward.core.plugin_manager.download_and_install_plugin", new=install), \ patch("steward.settings.routes.log_audit", new=AsyncMock()): resp = await client.post("/settings/plugins/install/demo", headers={"X-CSRF-Token": "t"}, form={ "download_url": "https://attacker/evil.zip", "checksum_sha256": "dead"}) assert resp.status_code == 200 assert install.await_args.args[1:] == ("demo", "https://catalog/demo.zip", "abc") install.reset_mock() resp = await client.post("/settings/plugins/install/other", headers={"X-CSRF-Token": "t"}) assert "No configured plugin repository lists this plugin" in await resp.get_data(as_text=True) install.assert_not_awaited()