diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index b2398611..12e74212 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -53,6 +53,17 @@ class PluginStoreManager: # "..", "../x") into a filesystem path that purge_uninstalled_plugins # would delete — an empty id resolves to the plugins root itself. _PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + @staticmethod + def is_plugin_entry(entry) -> bool: + """Whether a registry entry is a plugin core can install. + + A missing ``type`` means plugin. Anything else (registries used to + carry ``"type": "skin"`` entries, and a custom registry still can) is + hidden from the store and refused at install, rather than being + unpacked into the plugins directory as if it were a plugin. + """ + return isinstance(entry, dict) and (entry.get('type') or 'plugin') == 'plugin' def __init__(self, plugins_dir: str = "plugins", uninstalled_registry_path: Optional[str] = None): @@ -1314,6 +1325,10 @@ class PluginStoreManager: if not plugin_info: self.logger.error(f"Plugin not found in registry: {plugin_id}") return False + if not self.is_plugin_entry(plugin_info): + self.logger.error(f"Not installing {plugin_id}: registry entry type " + f"{plugin_info.get('type')!r} is not a plugin") + return False repo_url = plugin_info.get('repo') if not repo_url: diff --git a/test/test_store_non_plugin_entries.py b/test/test_store_non_plugin_entries.py new file mode 100644 index 00000000..af2195a6 --- /dev/null +++ b/test/test_store_non_plugin_entries.py @@ -0,0 +1,79 @@ +"""Registry entries that aren't plugins are hidden and refused. + +The official registry no longer lists skins, but a custom registry (added +from the Plugin Store) can still carry ``"type": "skin"`` entries. With the +skin system removed, installing one as a plugin would unpack it into the +plugins directory, so the store hides such entries and refuses them at install. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from src.plugin_system.store_manager import PluginStoreManager +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401 + +PLUGIN = {"id": "clock", "name": "Clock", "repo": "https://github.com/x/clock"} +UNTYPED = {"id": "news", "name": "News", "repo": "https://github.com/x/news"} +SKIN = {"id": "retro", "name": "Retro", "type": "skin", "repo": "https://github.com/x/retro"} + + +@pytest.mark.parametrize("entry,expected", [ + (PLUGIN, True), + ({**PLUGIN, "type": "plugin"}, True), + ({**PLUGIN, "type": None}, True), + (SKIN, False), + ({**PLUGIN, "type": "theme"}, False), + (None, False), + ("clock", False), +]) +def test_is_plugin_entry(entry, expected): + assert PluginStoreManager.is_plugin_entry(entry) is expected + + +def test_install_refuses_a_non_plugin_entry(tmp_path): + store = PluginStoreManager(plugins_dir=str(tmp_path / "plugins")) + store.get_plugin_info = MagicMock(return_value=dict(SKIN)) + store._install_from_monorepo_zip = MagicMock() + store._install_via_git = MagicMock() + assert store._install_plugin_impl("retro") is False + assert not (tmp_path / "plugins" / "retro").exists() + store._install_from_monorepo_zip.assert_not_called() + store._install_via_git.assert_not_called() + + +@pytest.fixture +def store(api_v3_module): + mock = MagicMock() + mock.is_plugin_entry = PluginStoreManager.is_plugin_entry + api_v3_module.api_v3.plugin_store_manager = mock + return mock + + +def test_custom_registry_listing_hides_non_plugins(api_v3_client, store): + store.fetch_registry_from_url.return_value = {"plugins": [PLUGIN, SKIN, UNTYPED]} + resp = api_v3_client.post("/api/v3/plugins/registry-from-url", + data=json.dumps({"repo_url": "https://github.com/x/registry"}), + content_type="application/json") + assert resp.status_code == 200 + assert [p["id"] for p in resp.get_json()["plugins"]] == ["clock", "news"] + + +def test_store_listing_hides_non_plugins(api_v3_client, store): + store.search_plugins.return_value = [PLUGIN, SKIN, UNTYPED] + resp = api_v3_client.get("/api/v3/plugins/store/list") + assert resp.status_code == 200 + body = resp.get_json() + listed = body.get("data", body).get("plugins", []) + assert [p["id"] for p in listed] == ["clock", "news"] + + +def test_install_route_refuses_a_non_plugin_entry(api_v3_client, store): + store.get_registry_info.return_value = dict(SKIN) + resp = api_v3_client.post("/api/v3/plugins/install", + data=json.dumps({"plugin_id": "retro"}), + content_type="application/json") + assert resp.status_code == 400 + assert "not a plugin" in resp.get_json()["message"] + store.install_plugin.assert_not_called() diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 6fb1e8ac..b293d321 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -1296,6 +1296,17 @@ def install_plugin(): plugin_id = data['plugin_id'] branch = data.get('branch') # Optional branch parameter + # A registry entry that isn't a plugin (a custom registry can still + # list old "type": "skin" entries) gets a clear refusal, not a failed + # install. + try: + registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id) + except Exception: + registry_entry = None + if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry): + return jsonify({'status': 'error', + 'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400 + # Install the plugin # Log the plugins directory being used for debugging plugins_dir = api_v3.plugin_store_manager.plugins_dir @@ -1498,7 +1509,8 @@ def get_registry_from_url(): if registry: return jsonify({ 'status': 'success', - 'plugins': registry.get('plugins', []), + 'plugins': [p for p in registry.get('plugins', []) + if api_v3.plugin_store_manager.is_plugin_entry(p)], 'registry_url': repo_url }) else: @@ -1613,6 +1625,8 @@ def list_plugin_store(): # Format plugins for the web interface formatted_plugins = [] for plugin in plugins: + if not api_v3.plugin_store_manager.is_plugin_entry(plugin): + continue formatted_plugins.append({ 'id': plugin.get('id'), 'name': plugin.get('name'),