Compare commits

...
Author SHA1 Message Date
SAPHID 9bc5294f36 Reject Decky plugin name collisions during updates 2026-10-26 00:15:48 +00:00
3 changed files with 75 additions and 5 deletions

No files matched your search

+5 -2
View File
@@ -88,8 +88,11 @@ entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
back an update if the service restart fails. An update is also refused if its
new plugin name is already used by another installed plugin, because Decky
routes backend calls by name. This collision check has local tests only.
The current VR shell does not provide Decky's Steam Deck Quick Access Menu.
Frame Control's headset panel
shows installed plugins and store actions. Its separate Framey iframe hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
+51
View File
@@ -181,6 +181,27 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
def test_remote_apply_rechecks_plugin_name_under_transaction_lock(self):
data, _ = archive()
other = self.folder.parent / 'Other'
other.mkdir()
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertFalse(self.folder.exists())
self.assertEqual((other / 'plugin.json').read_text(), '{"name":"Example"}')
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
(other / 'plugin.json').write_text('{"name":"Distinct"}')
self.assertEqual(self.apply('install', data).returncode, 0)
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('update', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertTrue(self.folder.exists())
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
class StoreHost(unittest.TestCase):
def test_reviewed_archive_changed_before_install_does_not_download(self):
@@ -219,6 +240,36 @@ class StoreHost(unittest.TestCase):
'install', 42)
network.assert_not_called()
def test_update_rejects_name_taken_by_another_plugin_before_download(self):
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Old Name', 'version': '1.0'},
{'storeId': 43, 'folder': 'Other', 'name': 'Example', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': 'a' * 64, 'version': '2.0'}]
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen',
side_effect=AssertionError('No download expected')):
with self.assertRaisesRegex(ValueError, 'different plugin with this name'):
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
'update', 42)
def test_update_allows_its_own_existing_name(self):
data, digest = archive()
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': digest, 'version': '2.0'}]
calls = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(data)):
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
'update', 42)
self.assertEqual(result, {})
self.assertEqual(len(calls), 3) # Upload, apply, and temporary archive cleanup.
self.assertIn('update', calls[1])
def test_owned_plugin_can_be_removed_without_store_network(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
+19 -3
View File
@@ -175,6 +175,19 @@ try:
z.extractall(stage)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
# Recheck under the transaction lock. Another store operation may
# have installed a colliding name while this archive downloaded.
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
for peer in base.iterdir():
if peer==target or peer.is_symlink() or not peer.is_dir(): continue
manifest=peer/'plugin.json'
if manifest.is_symlink() or not manifest.is_file(): continue
try:
if manifest.stat().st_size>16384: continue
peer_data=json.loads(manifest.read_text())
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
raise RuntimeError('A different plugin with this name is already installed')
except (OSError,ValueError,UnicodeError): continue
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
# Stop even a failed unit before removal to cancel any pending auto-restart.
if running or action=='remove': service('stop','frame-control-decky.service')
@@ -228,9 +241,12 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
raise ValueError('Root plugins are not supported by the Frame user service')
if plugin.get('compatibility', {}).get('blockInstall'):
raise ValueError(plugin['compatibility']['reason'])
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
if action == 'install' and existing_name:
raise ValueError('A plugin with this name is already installed')
# Decky routes backend calls by plugin name. A store update may rename
# its plugin, so check names for updates as well as fresh installs.
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']
and (action == 'install' or p.get('storeId') != plugin_id)]
if existing_name:
raise ValueError('A different plugin with this name is already installed')
if action == 'update' and len(installed) != 1:
raise ValueError('Only an installed Frame Control store plugin can be updated')
data = None