Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/plugin_system/store_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,26 @@ def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: st
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
}

# Refuse a plugin that needs a newer core than this one, exactly as
# _install_plugin_impl does after its download. Sideloading is an
# explicit act rather than an automatic store update, but the floor
# is not advice about intent -- it is a statement that the plugin
# cannot run here, and letting it through produces the same silent
# PluginState.ERROR at load. This was the last of the three routes
# in that skipped the check.
#
# Before the move, so the `finally` below removes the temp tree and
# nothing half-installed is left behind.
from src import __version__ as core_version
from src.plugin_system import compatibility

compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
self.logger.error(
"Refusing to install %s from %s: %s",
plugin_id, repo_url, reason)
return {'success': False, 'error': reason}

# Validate version fields consistency (warnings only, not required)
validation_errors = self._validate_manifest_version_fields(manifest)
if validation_errors:
Expand Down
72 changes: 70 additions & 2 deletions test/test_plugin_compatibility_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ class TestInstallGate:
rediscovering it. `update_plugin` also has a git branch that pulls in
place and never re-downloads; that one is gated separately by
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
route, `install_from_url`, is still ungated — sideloading from a URL
checks required fields but not the floor.
route, `install_from_url` (sideloading from a URL), is gated in that
function and pinned in `TestSideloadGate`. All three refuse on the same
rule.
"""

def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
Expand Down Expand Up @@ -217,6 +218,73 @@ def test_untrustworthy_core_does_not_block_installs(self, store, monkeypatch):
assert (path / "manifest.json").exists()


class TestSideloadGate:
"""`install_from_url` never looked at the core version.

Sideloading is an explicit act rather than an automatic store update, so
the argument for gating it is different: not "the user did not choose
this", but that the floor states the plugin *cannot run here*. Letting it
through produces the same silent PluginState.ERROR at load that the store
gate exists to prevent, and the user who typed the URL is no better placed
to diagnose it than one who pressed Update.
"""

def _sideload(self, store, manifest, core_version, monkeypatch):
mgr, plugins_dir = store
plugin_id = manifest["id"]

def fake_clone(repo_url, target_path, branches=None):
target_path.mkdir(parents=True, exist_ok=True)
(target_path / "manifest.json").write_text(
json.dumps(manifest), encoding="utf-8")
(target_path / "manager.py").write_text(
"class P: pass\n", encoding="utf-8")
return "main"

monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)

import src
monkeypatch.setattr(src, "__version__", core_version)
return mgr.install_from_url("https://example.invalid/plugin"), \
plugins_dir / plugin_id

def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
manifest = {
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
}
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)

assert result["success"] is False
assert "9.9.9" in result["error"], result["error"]
assert not path.exists(), (
"a refused sideload must not leave the plugin installed")

def test_allows_a_compatible_plugin(self, store, monkeypatch):
"""The guard against over-refusing: a gate that blocks everything
passes the test above and breaks sideloading entirely."""
manifest = {
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
}
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)

assert result["success"] is True, result.get("error")
assert (path / "manifest.json").exists()

def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
self, store, monkeypatch):
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
and nearly every published manifest floors at 2.0.0."""
manifest = {
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
}
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)

assert result["success"] is True, result.get("error")

# --------------------------------------------------------------------------
# The git-pull update path — the one route that does not re-download
# --------------------------------------------------------------------------
Expand Down
Loading