mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 17:01:38 +01:00
* Allow safe Decky plugin removal when loader is stopped * Stop failed Decky unit before recovery removal --------- Co-authored-by: SAPHID <saphid@users.noreply.github.com>
190 lines
9.3 KiB
Python
190 lines
9.3 KiB
Python
"""Official Decky store transport and owned plugin transaction."""
|
|
import sandbox # noqa: F401
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import zipfile
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
|
import frame_decky_store
|
|
import frame_decky
|
|
|
|
|
|
def archive(flags=(), extra=None):
|
|
stream = io.BytesIO()
|
|
with zipfile.ZipFile(stream, 'w') as bundle:
|
|
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)}))
|
|
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
|
|
if extra:
|
|
bundle.writestr(*extra)
|
|
data = stream.getvalue()
|
|
return data, hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
class ArchiveTests(unittest.TestCase):
|
|
def test_catalog_selects_valid_visible_latest_versions(self):
|
|
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
|
|
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
|
|
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
|
|
[{'name': '1.0', 'hash': 'b' * 64}]},
|
|
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
|
|
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
|
|
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
|
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
|
|
|
|
def test_accepts_hashed_non_root_archive(self):
|
|
data, digest = archive()
|
|
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
|
with self.assertRaisesRegex(ValueError, 'SHA-256'):
|
|
frame_decky_store.archive_info(data, '0' * 64, 'Example')
|
|
|
|
def test_rejects_root_x86_and_escape(self):
|
|
for data in (archive(flags=['root'])[0],
|
|
archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0],
|
|
archive(extra=('../outside', b'oops'))[0]):
|
|
with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]):
|
|
with self.assertRaises(ValueError):
|
|
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
|
|
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
|
|
class StoreTransaction(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
home = Path(self.tmp.name)
|
|
unit = home / '.config/systemd/user/frame-control-decky.service'
|
|
unit.parent.mkdir(parents=True)
|
|
unit.write_text('[Service]\n')
|
|
fake = home / 'systemctl'
|
|
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n')
|
|
fake.chmod(0o700)
|
|
self.env = {**os.environ, 'HOME': self.tmp.name,
|
|
'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
|
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
|
self.folder.parent.mkdir(parents=True)
|
|
|
|
def apply(self, action, data=None):
|
|
token = secrets.token_hex(12)
|
|
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
|
if data is not None:
|
|
source.write_bytes(data)
|
|
digest = hashlib.sha256(data or b'').hexdigest()
|
|
try:
|
|
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
|
|
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
|
env=self.env, timeout=15)
|
|
finally:
|
|
source.unlink(missing_ok=True)
|
|
|
|
def test_install_update_remove_only_owned_folder(self):
|
|
first, _ = archive()
|
|
result = self.apply('install', first)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42)
|
|
second, _ = archive(extra=('Example/data.txt', 'new'))
|
|
result = self.apply('update', second)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual((self.folder / 'data.txt').read_text(), 'new')
|
|
result = self.apply('remove')
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertFalse(self.folder.exists())
|
|
self.folder.mkdir()
|
|
(self.folder / 'plugin.json').write_text('{}')
|
|
result = self.apply('remove')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertTrue(self.folder.exists())
|
|
external = Path(self.tmp.name) / 'other-plugin'
|
|
external.mkdir()
|
|
(external / '.frame-control-store.json').write_text('{"storeId":42}')
|
|
for item in self.folder.iterdir(): item.unlink()
|
|
self.folder.rmdir()
|
|
self.folder.symlink_to(external, target_is_directory=True)
|
|
result = self.apply('remove')
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertTrue(external.exists())
|
|
|
|
def test_remove_while_service_is_stopped_does_not_restart_it(self):
|
|
data, _ = archive()
|
|
self.assertEqual(self.apply('install', data).returncode, 0)
|
|
fake = Path(self.tmp.name) / 'systemctl'
|
|
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 1; fi\n'
|
|
'echo "$*" >> "$HOME/service-calls"\nexit 0\n')
|
|
result = self.apply('remove')
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertFalse(self.folder.exists())
|
|
self.assertEqual((Path(self.tmp.name) / 'service-calls').read_text().splitlines(),
|
|
['--user stop frame-control-decky.service'])
|
|
|
|
def test_shared_lock_rejects_plugin_and_loader_changes(self):
|
|
import fcntl
|
|
|
|
lock_path = Path(self.tmp.name) / '.local/share/frame-control/decky-transaction.lock'
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
data, _ = archive()
|
|
with lock_path.open('w+b') as held:
|
|
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
plugin = self.apply('install', data)
|
|
loader = subprocess.run([sys.executable, '-', 'remove', 'none', 'none', 'none'],
|
|
input=frame_decky.APPLY, text=True, capture_output=True,
|
|
env=self.env, timeout=15)
|
|
self.assertNotEqual(plugin.returncode, 0)
|
|
self.assertNotEqual(loader.returncode, 0)
|
|
self.assertIn('Another Decky change is in progress', plugin.stderr)
|
|
self.assertIn('Another Decky change is in progress', loader.stderr)
|
|
self.assertFalse(self.folder.exists())
|
|
|
|
installed = self.apply('install', data)
|
|
self.assertEqual(installed.returncode, 0, installed.stderr)
|
|
self.assertEqual(self.apply('remove').returncode, 0)
|
|
|
|
|
|
class StoreHost(unittest.TestCase):
|
|
def test_stopped_managed_service_allows_removing_owned_plugin(self):
|
|
state = {'managed': True, 'running': False, 'plugins':
|
|
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
|
calls = []
|
|
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
|
patch.object(frame_decky_store, 'catalog') as network:
|
|
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
|
|
'remove', 42)
|
|
self.assertEqual(result, {})
|
|
self.assertEqual(len(calls), 1)
|
|
network.assert_not_called()
|
|
|
|
def test_renamed_store_plugin_cannot_be_installed_twice(self):
|
|
state = {'managed': True, 'running': True, 'plugins':
|
|
[{'storeId': 42, 'folder': 'OldFolder', 'name': 'Old Name', 'version': '1.0'}]}
|
|
catalog = [{'id': 42, 'name': 'New Name', '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) as network, \
|
|
patch.object(frame_decky_store.urllib.request, 'urlopen', side_effect=AssertionError('No download expected')):
|
|
with self.assertRaisesRegex(ValueError, 'already installed'):
|
|
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
|
'install', 42)
|
|
network.assert_not_called()
|
|
|
|
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'}]}
|
|
def ssh(command, **kwargs):
|
|
return '{}'
|
|
progress = []
|
|
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
|
patch.object(frame_decky_store, 'catalog') as network:
|
|
frame_decky_store.change(ssh, 'remove', 42, lambda stage, percent: progress.append((stage, percent)))
|
|
network.assert_not_called()
|
|
self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|