mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
fix(starlark): toggle the app id the list published, and store a relocatable star_file (#537)
The two review nitpicks left over from #535. Both are still on main after that merge; the five findings alongside them landed with it. **The toggle could not find what the list had just shown.** `_starlark_virtual_plugins` publishes the raw manifest key as `starlark:<key>`, and `_toggle_starlark_app` passed it back through `_validate_and_sanitize_app_id`, which lowercases and rewrites every character outside `[a-z0-9_]`. An app stored as `My-App` was listed as `starlark:My-App` and looked up as `my_app`, so toggling an app the page had drawn a moment earlier answered 404. Keys written by `_install_star_file` are already sanitised, so this only shows up for manifests written by the starlark-apps plugin itself or edited by hand. `_validate_starlark_app_path` rejects traversal without rewriting, so it is the check to use here -- listing and toggling now agree on one key. The updater also uses `setdefault` rather than indexing: the app is loaded but its on-disk entry need not exist, and `_update_manifest_safe` does not catch `KeyError`, so that escaped as a 500 rather than writing the entry. **`star_file` was stored absolute.** Readers join it to the app's own directory -- `_standalone_render_starlark_app` does `app_dir / app_data.get('star_file', f'{app_id}.star')` -- so the key's default is a bare filename and an absolute value gave it a second meaning. Since `Path.__truediv__` discards the left side when the right is absolute, the manifest was pinned to whatever PROJECT_ROOT installed it, and a moved or redeployed install could not find its own file. Storing `dest.name` matches the default and stays relocatable. Read paths are unchanged, so manifests already holding an absolute path keep working. 7 new tests. Whole suite: no new failures against main, 4013 passed against 4007. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ reads, so a future rewrite of this file cannot silently drop them again.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -305,4 +306,90 @@ class TestInstalledAppsAppearWithTheOtherPlugins:
|
|||||||
resp = client.post('/api/v3/plugins/toggle',
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert 'invalid characters' in resp.get_json()['message']
|
assert 'traversal' in resp.get_json()['message']
|
||||||
|
|
||||||
|
def test_an_app_id_the_listing_published_can_be_toggled(self, client):
|
||||||
|
"""The id here is exactly what _starlark_virtual_plugins publishes.
|
||||||
|
|
||||||
|
It used to be re-slugified on the way back in -- lowercased, with every
|
||||||
|
character outside [a-z0-9_] replaced -- so an app stored as 'My-App'
|
||||||
|
was listed as 'starlark:My-App' and looked up as 'my_app'. Toggling an
|
||||||
|
app the page had just drawn answered 404.
|
||||||
|
"""
|
||||||
|
manifest = {'apps': {'My-App': {'name': 'My App', 'enabled': False}}}
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||||
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
||||||
|
return_value=manifest), \
|
||||||
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
||||||
|
return_value=True) as write:
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:My-App', 'enabled': True})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert write.called, "the toggle never reached the manifest"
|
||||||
|
assert manifest['apps']['My-App']['enabled'] is True
|
||||||
|
|
||||||
|
def test_a_loaded_app_missing_from_the_manifest_does_not_500(self, client):
|
||||||
|
"""_update_manifest_safe does not catch KeyError, so indexing an entry
|
||||||
|
that is not on disk yet escaped as a 500 instead of writing it."""
|
||||||
|
app = MagicMock()
|
||||||
|
app.manifest = {'enabled': False}
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.apps = {'demo': app}
|
||||||
|
written = {}
|
||||||
|
|
||||||
|
def run_updater(fn):
|
||||||
|
fn(written)
|
||||||
|
return True
|
||||||
|
|
||||||
|
plugin._update_manifest_safe.side_effect = run_updater
|
||||||
|
|
||||||
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
||||||
|
resp = client.post('/api/v3/plugins/toggle',
|
||||||
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert written['apps']['demo']['enabled'] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheManifestStaysRelocatable:
|
||||||
|
"""`star_file` is joined to the app's own directory by its readers.
|
||||||
|
|
||||||
|
_standalone_render_starlark_app does `app_dir / app_data.get('star_file',
|
||||||
|
f'{app_id}.star')`, so the key's default is a bare filename. Storing an
|
||||||
|
absolute path gave it a second meaning, and Path.__truediv__ discards the
|
||||||
|
left side when the right is absolute -- which pinned the manifest to the
|
||||||
|
PROJECT_ROOT that installed it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def starlark_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
apps_dir.mkdir()
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
return apps_dir
|
||||||
|
|
||||||
|
def _install(self, tmp_path):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
source = tmp_path / "source.star"
|
||||||
|
source.write_text("# app")
|
||||||
|
with patch.object(module, '_get_pixlet_renderer_class',
|
||||||
|
side_effect=ImportError("no pixlet here")):
|
||||||
|
assert module._install_star_file('demo', str(source), {'name': 'Demo'})
|
||||||
|
return json.loads((module._STARLARK_MANIFEST_FILE).read_text())['apps']['demo']
|
||||||
|
|
||||||
|
def test_the_star_file_is_recorded_by_name(self, starlark_dir, tmp_path):
|
||||||
|
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||||
|
|
||||||
|
def test_the_recorded_path_is_not_absolute(self, starlark_dir, tmp_path):
|
||||||
|
"""An absolute value survives a move only by accident."""
|
||||||
|
assert not os.path.isabs(self._install(tmp_path)['star_file'])
|
||||||
|
|
||||||
|
def test_the_value_resolves_against_the_app_directory(self, starlark_dir, tmp_path):
|
||||||
|
"""Which is the one thing every reader of this key does with it."""
|
||||||
|
entry = self._install(tmp_path)
|
||||||
|
assert (starlark_dir / 'demo' / entry['star_file']).is_file()
|
||||||
|
|
||||||
|
def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_path):
|
||||||
|
"""Stored and defaulted values must mean the same thing."""
|
||||||
|
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||||
|
|||||||
@@ -9144,7 +9144,13 @@ def _install_star_file(app_id: str, star_file_path: str, metadata: Dict[str, Any
|
|||||||
'render_interval': metadata.get('render_interval', 300),
|
'render_interval': metadata.get('render_interval', 300),
|
||||||
'display_duration': metadata.get('display_duration', 15),
|
'display_duration': metadata.get('display_duration', 15),
|
||||||
'config': metadata.get('config', {}),
|
'config': metadata.get('config', {}),
|
||||||
'star_file': str(dest),
|
# The filename, not the full path. Readers join this to the app's own
|
||||||
|
# directory and fall back to a bare '<app_id>.star', so an absolute
|
||||||
|
# value gave the key two meanings -- and Path.__truediv__ discards the
|
||||||
|
# left side when the right is absolute, which pinned the manifest to
|
||||||
|
# whatever PROJECT_ROOT installed it. Moving or redeploying the install
|
||||||
|
# then left the app unable to find its own file.
|
||||||
|
'star_file': dest.name,
|
||||||
}
|
}
|
||||||
return _write_starlark_manifest(manifest)
|
return _write_starlark_manifest(manifest)
|
||||||
|
|
||||||
@@ -9796,14 +9802,28 @@ def _starlark_virtual_plugins() -> list:
|
|||||||
|
|
||||||
def _toggle_starlark_app(app_id: str, enabled: bool):
|
def _toggle_starlark_app(app_id: str, enabled: bool):
|
||||||
"""Enable or disable one Starlark app, loaded or not."""
|
"""Enable or disable one Starlark app, loaded or not."""
|
||||||
safe_id, err = _validate_and_sanitize_app_id(app_id)
|
# Check for traversal, but toggle the key that was listed.
|
||||||
|
# _starlark_virtual_plugins publishes the raw manifest key, while
|
||||||
|
# _validate_and_sanitize_app_id lowercases it and rewrites every character
|
||||||
|
# outside [a-z0-9_]: an app stored as 'My-App' was offered to the UI as
|
||||||
|
# 'starlark:My-App' and looked up here as 'my_app', so toggling an app the
|
||||||
|
# page had just drawn answered 404. Keys written by _install_star_file are
|
||||||
|
# already sanitised; ones written by the plugin, or edited by hand, are
|
||||||
|
# not. _validate_starlark_app_path rejects traversal without rewriting.
|
||||||
|
_, err = _validate_starlark_app_path(app_id)
|
||||||
if err:
|
if err:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid app_id: {err}'}), 400
|
# err already names app_id; do not prefix it a second time.
|
||||||
|
return jsonify({'status': 'error', 'message': err}), 400
|
||||||
|
safe_id = app_id
|
||||||
|
|
||||||
plugin = _get_starlark_plugin()
|
plugin = _get_starlark_plugin()
|
||||||
if plugin is not None and safe_id in getattr(plugin, 'apps', {}):
|
if plugin is not None and safe_id in getattr(plugin, 'apps', {}):
|
||||||
def _update(manifest):
|
def _update(manifest):
|
||||||
manifest['apps'][safe_id]['enabled'] = enabled
|
# setdefault rather than indexing: the app is loaded, but its
|
||||||
|
# on-disk entry need not exist, and _update_manifest_safe does not
|
||||||
|
# catch KeyError -- it would escape as a 500 rather than the error
|
||||||
|
# this returns.
|
||||||
|
manifest.setdefault('apps', {}).setdefault(safe_id, {})['enabled'] = enabled
|
||||||
|
|
||||||
if plugin._update_manifest_safe(_update) is False:
|
if plugin._update_manifest_safe(_update) is False:
|
||||||
return jsonify({'status': 'error',
|
return jsonify({'status': 'error',
|
||||||
|
|||||||
Reference in New Issue
Block a user