From bdbd0f45a964c7902a8786a97429a7a99ddb87f1 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 28 Sep 2026 23:53:48 +0800 Subject: [PATCH] fix(tui): treat malformed read payloads as a stable error instead of crashing A server or proxy that breaks the read contract (non-object data, non-list items, non-object items, non-string cursor, missing profile path or item identifiers) previously raised inside the command loop and ended the TUI. Report invalid_read_response, clear the selectable page, keep explicit retry, and never dereference entries without a readable identifier. Refs #82. Co-authored-by: Cursor --- src/msg/tui.py | 49 ++++++++++++---- tests/test_tui_transport_boundaries.py | 77 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/msg/tui.py b/src/msg/tui.py index 631df51f..23605b41 100644 --- a/src/msg/tui.py +++ b/src/msg/tui.py @@ -9,6 +9,7 @@ import shutil import sys import unicodedata +from collections.abc import Mapping from urllib.parse import urlsplit import httpx @@ -131,8 +132,17 @@ async def _read(self, operation, arguments): self.page = None self._read_error(getattr(getattr(result, 'error', None), 'code', None)) return None + if not isinstance(result.data, Mapping): + return self._invalid_response() return result.data + def _invalid_response(self): + # A server/proxy that breaks the read contract must not crash the loop + # or leave the previous page selectable. + self.page = None + self._read_error('invalid_read_response') + return None + def _read_error(self, code): # Exception text/URL/details may contain credentials. Only contract codes # reach the terminal; a retry always re-enters the signed read contract. @@ -141,10 +151,18 @@ def _read_error(self, code): self._write('读取失败:' + code) self._write('retry 重新读取;不自动提交 ACK 或其他写入。') + @staticmethod + def _item_id(item): + ref = item.get('ref') or item.get('resource') + for rid in (item.get('id'), ref.get('id') if isinstance(ref, Mapping) else None, + item.get('conversation_id')): + if isinstance(rid, str) and rid: + return rid + return None + def _show_items(self, items): for index, item in enumerate(items, 1): - ref = item.get('ref') or item.get('resource') or {} - rid = item.get('id') or ref.get('id') or item.get('conversation_id') or '' + rid = self._item_id(item) or '' name = item.get('title') or item.get('name') or item.get('path') or item.get('source') or rid self._write(f'{index}. {name} {rid}') snippet = item.get('snippet') @@ -159,8 +177,12 @@ async def _show_page(self, operation, arguments, title): if data is None: return items = data.get('items', []) - self.page = {'operation': operation, 'arguments': arguments, - 'cursor': data.get('next_after_name') if operation == 'identity.todo_list' else data.get('cursor'), + cursor = data.get('next_after_name') if operation == 'identity.todo_list' else data.get('cursor') + if (not isinstance(items, (list, tuple)) or not all(isinstance(item, Mapping) for item in items) + or not (cursor is None or isinstance(cursor, str))): + self._invalid_response() + return + self.page = {'operation': operation, 'arguments': arguments, 'cursor': cursor, 'title': title, 'items': items} self._write(title) if items: @@ -202,7 +224,10 @@ async def files(self): return self._remember_read(self.files) profile = await self._read('discovery.get', {'id': subject, 'fields': ['path']}) - if profile is not None: + if profile is not None and not (isinstance(profile.get('path'), str) + and profile['path'].startswith('/')): + self._invalid_response() + elif profile is not None: await self._show_page('discovery.read_query', {'parent': profile['path'] + '/files', 'limit': 20}, 'Files') @@ -310,13 +335,17 @@ async def command(self, raw): index = int(command) - 1 if len(command) <= 6 else -1 if 0 <= index < len(self.page['items']): item = self.page['items'][index] - ref = item.get('ref') or item.get('resource') or {} - rid = item.get('id') or ref.get('id') or item.get('conversation_id') if self.page['operation'] in {'identity.note_list', 'identity.todo_list'}: - operation = self.page['operation'].replace('_list', '_get') - await self._document(operation, {'name': item['name']}, item['name']) - else: + name = item.get('name') + if isinstance(name, str) and name: + operation = self.page['operation'].replace('_list', '_get') + await self._document(operation, {'name': name}, name) + else: + self._write('该条目没有可读取的标识。') + elif rid := self._item_id(item): await self.read(rid) + else: + self._write('该条目没有可读取的标识。') else: self._write('序号不在本页。') else: diff --git a/tests/test_tui_transport_boundaries.py b/tests/test_tui_transport_boundaries.py index cbd8f8f9..bf1471cb 100644 --- a/tests/test_tui_transport_boundaries.py +++ b/tests/test_tui_transport_boundaries.py @@ -85,6 +85,83 @@ async def test_oversized_selection_is_rejected_without_integer_conversion_crash( assert '序号不在本页' in ui.stdout.getvalue() +@pytest.mark.asyncio +@pytest.mark.parametrize('payload', [ + ['not', 'an', 'object'], 'text', None, 7, + {'items': 'abc'}, {'items': ['x']}, {'items': [{'id': 'ok'}, None]}, + {'items': [], 'cursor': {'forged': 'cursor'}}, {'items': [], 'cursor': 5}, +]) +async def test_malformed_read_payload_is_a_stable_error_not_a_crash(payload): + client = Client([{'items': [{'id': 'old', 'name': 'OLD'}], 'cursor': 'old-cursor'}, + payload, {'items': [{'id': 'new', 'name': 'FRESH'}]}]) + output = StringIO() + ui = TerminalUI(client, stdout=output, width=160) + await ui.home() + await ui.next_page() + assert ui.page is None + assert '读取失败:invalid_read_response' in output.getvalue() + await ui.command('1') + await ui.command('n') + assert len(client.calls) == 2 + await ui.command('retry') + assert client.calls[1] == client.calls[2] + assert ui.page['items'][0]['id'] == 'new' + + +@pytest.mark.asyncio +async def test_frozen_client_payload_is_still_a_valid_page(): + from msg.core.codec import freeze_json + client = Client([freeze_json({'items': [{'ref': {'id': 'r_frozen'}, 'name': 'FROZEN'}], + 'cursor': 'next'})]) + output = StringIO() + ui = TerminalUI(client, stdout=output, width=160) + await ui.home() + assert ui.page['cursor'] == 'next' + assert '1. FROZEN r_frozen' in output.getvalue() + + +@pytest.mark.asyncio +async def test_items_without_readable_identifier_are_not_dereferenced(): + client = Client([{'items': [{'name': 'no id'}, {'ref': 'not-a-dict'}, + {'resource': {'id': ['list']}}, {'id': 'r_ok', 'ref': 'x'}]}]) + output = StringIO() + ui = TerminalUI(client, stdout=output, width=160) + await ui.home() + for command in ('1', '2', '3'): + await ui.command(command) + assert len(client.calls) == 1 + assert output.getvalue().count('该条目没有可读取的标识。') == 3 + client.replies.append({'path': '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/main/r_ok.md', 'content': 'OK'}) + await ui.command('4') + assert client.calls[-1] == ('discovery.get', {'id': 'r_ok'}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('name', [None, '', 5, {'x': 1}]) +async def test_note_or_todo_without_name_is_not_requested(name): + item = {'id': 'r_note'} if name is None else {'id': 'r_note', 'name': name} + client = Client([{'items': [item]}]) + output = StringIO() + ui = TerminalUI(client, stdout=output) + await ui.command('notes') + await ui.command('1') + assert client.calls == [('identity.note_list', {})] + assert '该条目没有可读取的标识。' in output.getvalue() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('profile', [{}, {'path': None}, {'path': 'relative'}, {'path': ['/x']}]) +async def test_files_rejects_profile_without_absolute_path(profile): + client = Client([profile, {'path': '/@alice'}, {'items': []}]) + output = StringIO() + ui = TerminalUI(client, stdout=output) + await ui.command('files') + assert client.calls == [('discovery.get', {'id': 'alice', 'fields': ['path']})] + assert 'invalid_read_response' in output.getvalue() + await ui.command('retry') + assert client.calls[-1] == ('discovery.read_query', {'parent': '/@alice/files', 'limit': 20}) + + @pytest.mark.asyncio async def test_inflight_read_from_previous_identity_is_not_displayed(): client = Client([])