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
49 changes: 39 additions & 10 deletions src/msg/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import shutil
import sys
import unicodedata
from collections.abc import Mapping
from urllib.parse import urlsplit

import httpx
Expand Down Expand Up @@ -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.
Expand All @@ -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')
Expand All @@ -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:
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions tests/test_tui_transport_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': '/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([])
Expand Down
Loading