diff --git a/docs/CLI_FIELD_SELECTION.md b/docs/CLI_FIELD_SELECTION.md new file mode 100644 index 00000000..ae822cb0 --- /dev/null +++ b/docs/CLI_FIELD_SELECTION.md @@ -0,0 +1,85 @@ +# CLI read field selection + +`msg call` supports an explicit `--contract-version` (default `1`, preserving +existing calls). `--json` is opt-in and uses the selected server Registry +contract. It is not a client-side truncation of a full business response. + +Discover ReadQuery v3 fields without executing the business query: + +```sh +msg call discovery.read_query --contract-version 3 --json +``` + +Select a bounded page using signed server-side projection: + +```sh +msg call discovery.read_query '{"parent":"/main","query_version":3,"limit":20}' \ + --contract-version 3 --json id,name,path +``` + +The unchanged JSON result envelope retains the cursor/next links and status. +For the next page, send only the returned cursor at the same contract version, +without repeating `--json`; the server-bound cursor retains the projection: + +```sh +msg call discovery.read_query '{"cursor":"SERVER_RETURNED_CURSOR"}' --contract-version 3 +``` + +A bare `--json` lists the `fields.items.enum` from the exact registered input +schema and does not enumerate resources. Contracts without this declared field +vocabulary return `json_fields_unavailable`; the client does not guess fields +from one object's data or import the server runtime. Non-read contracts return +`json_read_required` before business execution. Existing `fields`, a cursor, or +`--return-field` with `--json` returns `json_query_conflict`. Unknown, repeated, +empty, or whitespace-altered field names return `invalid_json_fields`. +Server schema failures retain the normal error result and nonzero exit status. + +## Optional local output processing + +Both options require explicit nonempty `--json FIELDS` on `msg call`; they are +mutually exclusive. Bare field discovery cannot be combined with formatting. +Only the returned authorized JSON envelope is processed, after a successful read. +Server error envelopes remain unchanged and return a nonzero status. + +```sh +msg call discovery.read_query '{"parent":"/main","query_version":3,"limit":20}' \ + --contract-version 3 --json id,name --jq '.data.items[] | .name' +msg call discovery.read_query '{"parent":"/main","query_version":3,"limit":1}' \ + --contract-version 3 --json id,name --template 'Name: {{/data/items/0/name}}' +``` + +`--jq` uses an installed `jq` executable directly with an argument vector, never +a shell. It produces compact JSON values, one per line. No executable gives +`jq_unavailable`; invalid filters give `invalid_jq_query`. The process receives +an empty environment and an empty temporary working/module directory. Filters +containing the words `import`, `include`, or `modulemeta` are conservatively +rejected, even inside quoted strings or comments: module loading is unsupported. +No other command-line options or filenames are passed to jq. Query text is +limited to 4 KiB; input and output to 1 MiB; execution to 2 seconds. Output is +bounded while streaming, and overflow/timeout kills and waits for the child. +Errors are stable codes and never include jq diagnostics or private input. +These are I/O and elapsed-time limits, not a general resource sandbox: jq +intermediate allocations remain subject to the host's process memory limits. +Run only filters you intentionally choose; server content is always data. + +`--template` supports only `{{JSON_POINTER}}` placeholders. JSON Pointer follows +object keys and array indices, never Python attributes: `/data/items/0/name` +selects that JSON value; `~1` escapes `/`, and `~0` escapes `~` in an object key. +`{{}}` selects the entire result. Values are substituted as JSON, so a string +is quoted and its terminal control characters are escaped. Literal template +text is preserved; the CLI appends a newline. There are no loops, expressions, +attribute traversal, template files or external access. Malformed placeholders, +invalid escapes, and missing pointers give `invalid_output_template`, not empty +success. Template text is limited to 4 KiB and the result to 1 MiB. + +This slice does not implement global output flags on every shortcut or automatic +pagination. Existing `read --field`, `search --fields`, mutation calls, +authentication and signing stay intact. Default output remains the unchanged +JSON envelope; templates explicitly opt into literal text output. Issue #82 +remains open for the other command and pagination contracts. + +## Test prerequisites + +The real formatter regression tests require `jq` on `PATH`; absence fails rather +than skipping those assertions. The signed HTTP/read-only test additionally +requires the standard disposable PostgreSQL fixture from `CONTRIBUTING.md`. diff --git a/src/msg/cli.py b/src/msg/cli.py index a0ef48af..1600f903 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -106,6 +106,20 @@ def parser(): ) call.add_argument('operation') call.add_argument('arguments', nargs='?', default='{}') + call.add_argument('--contract-version', type=int, default=1) + call.add_argument( + '--json', + dest='json_fields', + nargs='?', + const='', + metavar='FIELDS', + help='Select server-declared read fields; without FIELDS, list them without reading data.', + ) + output_format = call.add_mutually_exclusive_group() + output_format.add_argument( + '--jq', help='Run a bounded local jq filter on selected read output.' + ) + output_format.add_argument('--template', help='Local data-only JSON-pointer template.') call.add_argument('--request-id') call.add_argument('--expect', action='append', default=[], metavar='ID=GENERATION') call.add_argument('--return-field', action='append', default=[]) @@ -463,13 +477,30 @@ async def run(args): rid, sep, generation = item.rpartition('=') require(sep and generation.isdecimal(), 'invalid_expected_generation') expected.append((rid, int(generation))) - result = await client.call( - args.operation, - arguments(args.arguments), - request_id=args.request_id, - expected=expected, - return_fields=args.return_field, + require( + not (args.jq is not None or args.template is not None) + or args.json_fields not in {None, ''}, + 'json_fields_required', ) + params = arguments(args.arguments) + require(args.contract_version > 0, 'invalid_operation_version') + result = None + if args.json_fields is not None: + from msg.client_output import json_fields + + require(not args.return_field, 'json_query_conflict') + params, result = await json_fields( + client, args.operation, args.contract_version, params, args.json_fields + ) + if result is None: + result = await client.call( + args.operation, + params, + request_id=args.request_id, + expected=expected, + return_fields=args.return_field, + contract_version=args.contract_version, + ) elif command in {'money', 'store', 'bounty', 'orders', 'delivery'}: from msg.client_market import run_command @@ -923,7 +954,19 @@ async def run(args): and rendered.get('status') == 'ok' ): rendered['data'].pop('url', None) - print(canonical(rendered).decode()) + if command == 'call' and not ( + isinstance(result, OperationResult) and result.status != 'ok' + ): + from msg.client_output import render_jq, render_template + + if args.jq is not None: + print(await render_jq(args.jq, rendered), end='') + elif args.template is not None: + print(render_template(args.template, rendered)) + else: + print(canonical(rendered).decode()) + else: + print(canonical(rendered).decode()) return 1 if isinstance(result, OperationResult) and result.status == 'error' else 0 finally: await transport.close() diff --git a/src/msg/client_output.py b/src/msg/client_output.py new file mode 100644 index 00000000..1b8e0601 --- /dev/null +++ b/src/msg/client_output.py @@ -0,0 +1,167 @@ +"""Read field selection comes from the exact server Registry contract.""" + +import asyncio +import re +import shutil +import tempfile +from collections.abc import Mapping +from contextlib import suppress + +from msg.core.codec import canonical +from msg.core.errors import Failure, require + + +async def json_fields(client, operation, version, arguments, selection): + """Return signed-query arguments or a discovery/error result, never both.""" + require('fields' not in arguments and 'cursor' not in arguments, 'json_query_conflict') + require(type(version) is int and version > 0, 'invalid_operation_version') + response = await client.call('discovery.schema', {'operation': f'{operation}@{version}'}) + if response.status != 'ok': + return None, response + data = response.data + require(isinstance(data, Mapping), 'invalid_read_response') + spec = data.get('operation') + require(isinstance(spec, Mapping) and spec.get('effect') == 'read', 'json_read_required') + schema = data.get('input') + require(isinstance(schema, Mapping), 'json_fields_unavailable') + properties = schema.get('properties') + require(isinstance(properties, Mapping), 'json_fields_unavailable') + field = properties.get('fields') + require(isinstance(field, Mapping), 'json_fields_unavailable') + items = field.get('items') + require(isinstance(items, Mapping), 'json_fields_unavailable') + available = items.get('enum') + require( + isinstance(available, (list, tuple)) + and available + and all(isinstance(name, str) and name for name in available), + 'json_fields_unavailable', + ) + if selection == '': + return None, {'status': 'ok', 'data': {'fields': list(available)}} + chosen = selection.split(',') + require( + len(chosen) == len(set(chosen)) and set(chosen) <= set(available), + 'invalid_json_fields', + ) + return {**arguments, 'fields': chosen}, None + + +MAX_FORMAT_BYTES = 1048576 +MAX_EXPRESSION_BYTES = 4096 +_POINTER = re.compile(r'\{\{(/[^{}]*)?\}\}') + + +def _bounded(value, maximum=MAX_FORMAT_BYTES): + require(len(value.encode('utf-8')) <= maximum, 'output_format_too_large') + return value + + +def render_template(template, data): + """Replace JSON-pointer placeholders with JSON values; never evaluate text.""" + _bounded(template, MAX_EXPRESSION_BYTES) + parts = [] + offset = 0 + size = 0 + for match in _POINTER.finditer(template): + literal = template[offset : match.start()] + require('{{' not in literal and '}}' not in literal, 'invalid_output_template') + value = data + pointer = match.group(1) + for token in pointer.split('/')[1:] if pointer is not None else (): + require(not re.search(r'~(?![01])', token), 'invalid_output_template') + key = token.replace('~1', '/').replace('~0', '~') + if isinstance(value, Mapping): + require(key in value, 'invalid_output_template') + value = value[key] + elif isinstance(value, (list, tuple)): + require( + re.fullmatch(r'0|[1-9][0-9]{0,8}', key) and int(key) < len(value), + 'invalid_output_template', + ) + value = value[int(key)] + else: + raise Failure('invalid_output_template') + replacement = canonical(value).decode() + size += len((literal + replacement).encode()) + require(size <= MAX_FORMAT_BYTES, 'output_format_too_large') + parts.extend((literal, replacement)) + offset = match.end() + tail = template[offset:] + require('{{' not in tail and '}}' not in tail, 'invalid_output_template') + parts.append(tail) + return _bounded(''.join(parts)) + + +async def render_jq(query, data, *, timeout=2): + """Run optional jq on one returned JSON value, with no inherited secrets.""" + _bounded(query, MAX_EXPRESSION_BYTES) + # Deliberately conservative, including quoted occurrences: jq modules may + # load filesystem data. This interface supports filters, not module loading. + require(not re.search(r'\b(?:import|include|modulemeta)\b', query), 'invalid_jq_query') + executable = shutil.which('jq') + require(executable is not None, 'jq_unavailable') + payload = canonical(data) + b'\n' + require(len(payload) <= MAX_FORMAT_BYTES, 'output_format_too_large') + with tempfile.TemporaryDirectory(prefix='msg-jq-') as directory: + try: + process = await asyncio.create_subprocess_exec( + executable, + '-c', + '-M', + '-L', + directory, + '--', + query, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + cwd=directory, + env={}, + ) + except OSError: + raise Failure('jq_unavailable') from None + + async def feed(): + try: + process.stdin.write(payload) + await process.stdin.drain() + except BrokenPipeError, ConnectionResetError: + pass + finally: + process.stdin.close() + with suppress(BrokenPipeError, ConnectionResetError): + await process.stdin.wait_closed() + + async def collect(): + chunks = [] + size = 0 + while block := await process.stdout.read(65536): + size += len(block) + require(size <= MAX_FORMAT_BYTES, 'output_format_too_large') + chunks.append(block) + return b''.join(chunks) + + tasks = [ + asyncio.create_task(feed()), + asyncio.create_task(collect()), + asyncio.create_task(process.wait()), + ] + try: + _, output, status = await asyncio.wait_for(asyncio.gather(*tasks), timeout) + require(status == 0, 'invalid_jq_query') + try: + return output.decode('utf-8') + except UnicodeError: + raise Failure('invalid_jq_output') from None + except TimeoutError: + raise Failure('output_format_timeout') from None + finally: + if process.returncode is None: + with suppress(ProcessLookupError): + process.kill() + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await process.wait() diff --git a/tests/test_cli_json_fields.py b/tests/test_cli_json_fields.py new file mode 100644 index 00000000..04f4cdde --- /dev/null +++ b/tests/test_cli_json_fields.py @@ -0,0 +1,383 @@ +"""CLI field discovery and projection use the exact signed read contract.""" + +from types import SimpleNamespace + +import pytest + +from msg import cli +from msg.core.errors import Failure + + +def test_call_accepts_explicit_contract_and_optional_json_fields(): + args = cli.parser().parse_args([ + 'call', + 'discovery.read_query', + '--contract-version', + '3', + '--json', + 'id,name', + ]) + assert args.contract_version == 3 + assert args.json_fields == 'id,name' + assert cli.parser().parse_args(['call', 'discovery.read_query', '--json']).json_fields == '' + + +class SchemaClient: + def __init__(self, *, effect='read', fields=('id', 'name'), status='ok'): + self.calls = [] + self.result = SimpleNamespace( + status=status, + data={ + 'operation': {'effect': effect}, + 'input': {'properties': {'fields': {'items': {'enum': list(fields)}}}}, + }, + ) + + async def call(self, operation, arguments): + self.calls.append((operation, arguments)) + return self.result + + +@pytest.mark.asyncio +async def test_bare_json_discovers_exact_schema_without_query(): + from msg.client_output import json_fields + + client = SchemaClient() + params, result = await json_fields(client, 'discovery.read_query', 3, {}, '') + assert params is None + assert result == {'status': 'ok', 'data': {'fields': ['id', 'name']}} + assert client.calls == [('discovery.schema', {'operation': 'discovery.read_query@3'})] + + +@pytest.mark.asyncio +async def test_json_selection_becomes_server_arguments_without_mutating_input(): + from msg.client_output import json_fields + + client = SchemaClient() + original = {'parent': '/', 'query_version': 3} + params, result = await json_fields(client, 'discovery.read_query', 3, original, 'name,id') + assert result is None + assert params == {**original, 'fields': ['name', 'id']} + assert 'fields' not in original + + +@pytest.mark.asyncio +@pytest.mark.parametrize('selection', ['id,id', ',id', 'id,', 'unknown', 'id, name']) +async def test_invalid_field_selection_fails_without_business_read(selection): + from msg.client_output import json_fields + + client = SchemaClient() + with pytest.raises(Failure, match='invalid_json_fields'): + await json_fields(client, 'discovery.read_query', 3, {}, selection) + assert all(op == 'discovery.schema' for op, _ in client.calls) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('arguments', [{'fields': ['id']}, {'cursor': 'opaque'}]) +async def test_json_rejects_existing_projection_or_cursor_before_network(arguments): + from msg.client_output import json_fields + + client = SchemaClient() + with pytest.raises(Failure, match='json_query_conflict'): + await json_fields(client, 'discovery.read_query', 3, arguments, 'id') + assert not client.calls + + +@pytest.mark.asyncio +async def test_json_never_executes_mutating_contract(): + from msg.client_output import json_fields + + client = SchemaClient(effect='transaction') + with pytest.raises(Failure, match='json_read_required'): + await json_fields(client, 'content.post_create', 1, {}, 'id') + assert client.calls == [('discovery.schema', {'operation': 'content.post_create@1'})] + + +@pytest.mark.asyncio +async def test_json_preserves_schema_error_and_missing_field_metadata_is_explicit(): + from msg.client_output import json_fields + + client = SchemaClient(status='error') + assert await json_fields(client, 'discovery.read_query', 3, {}, '') == (None, client.result) + client = SchemaClient(fields=()) + with pytest.raises(Failure, match='json_fields_unavailable'): + await json_fields(client, 'discovery.get', 1, {}, '') + + +@pytest.mark.asyncio +async def test_real_cli_json_discovery_projection_cursor_and_readonly_state( + installed, tmp_path, monkeypatch, capsys +): + import httpx + from read_only_evidence import readonly_evidence + from test_service import NOW + + from msg.client import ClientState, MsgClient + from msg.core.codec import canonical, loads + from msg.core.read_query import ROOT_FIELDS + from msg.transports.client import HTTPTransport + from msg.transports.http import create_app + + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + directory = tmp_path / 'json-client' + client = MsgClient( + ClientState(directory, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await client.register('json-client')).status == 'ok' + for body in ('first', 'second'): + assert ( + await client.call('content.post_create', {'parent': '/main', 'body': body}) + ).status == 'ok' + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + monkeypatch.setattr( + cli, + 'MsgClient', + lambda state, transport: MsgClient(state, transport, clock=lambda: NOW), + ) + + async def invoke(operation, arguments, *flags): + args = cli.parser().parse_args([ + '--config-dir', + str(directory), + '--server', + app.settings.service_url, + 'call', + operation, + canonical(arguments).decode(), + *flags, + ]) + status = await cli.run(args) + return status, loads(capsys.readouterr().out.encode()) + + async with readonly_evidence(app, monkeypatch): + status, listing = await invoke( + 'discovery.read_query', {}, '--contract-version', '3', '--json' + ) + assert status == 0 and listing == { + 'status': 'ok', + 'data': {'fields': list(ROOT_FIELDS)}, + } + status, page = await invoke( + 'discovery.read_query', + {'parent': '/main', 'query_version': 3, 'limit': 1}, + '--contract-version', + '3', + '--json', + 'id,name', + ) + assert status == 0 and page['status'] == 'ok' + assert len(page['data']['items']) == 1 and page['data']['cursor'] + assert set(page['data']['items'][0]) == {'id', 'name'} + for flag, expression in ( + ('--jq', '.data.items[0].name'), + ('--template', '{{/data/items/0/name}}'), + ): + status, name = await invoke( + 'discovery.read_query', + {'parent': '/main', 'query_version': 3, 'limit': 1}, + '--contract-version', + '3', + '--json', + 'id,name', + flag, + expression, + ) + assert status == 0 and name == page['data']['items'][0]['name'] + status, next_page = await invoke( + 'discovery.read_query', + {'cursor': page['data']['cursor']}, + '--contract-version', + '3', + ) + assert status == 0 and set(next_page['data']['items'][0]) == {'id', 'name'} + assert next_page['data']['items'][0]['id'] != page['data']['items'][0]['id'] + with pytest.raises(Failure, match='json_query_conflict'): + await invoke( + 'discovery.read_query', + {'cursor': page['data']['cursor']}, + '--contract-version', + '3', + '--json', + 'id', + ) + with pytest.raises(Failure, match='json_read_required'): + await invoke( + 'content.post_create', {'parent': '/main', 'body': 'never written'}, '--json' + ) + with pytest.raises(Failure, match='json_fields_unavailable'): + await invoke('discovery.get', {'id': '/main'}, '--json') + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('selection', 'formatter'), + [('', None), ('id,name', None), (None, None), ('id,name', '--jq'), ('id,name', '--template')], +) +async def test_real_cli_dispatch_keeps_version_and_machine_output( + selection, formatter, tmp_path, monkeypatch, capsys +): + from msg.core.codec import loads + from msg.core.models import OperationResult + + calls = [] + closed = [] + + class Transport: + async def close(self): + closed.append(True) + + class Client: + def __init__(self, state, transport): + pass + + async def call(self, operation, arguments, **kwargs): + calls.append((operation, arguments, kwargs)) + data = SchemaClient().result.data if operation == 'discovery.schema' else {'items': []} + return OperationResult( + request_id='test', + operation=operation, + status='ok', + actor=None, + subject=None, + resources=(), + data=data, + ) + + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: Transport()) + monkeypatch.setattr(cli, 'MsgClient', Client) + argv = [ + '--config-dir', + str(tmp_path), + '--server', + 'https://msg.invalid', + 'call', + 'discovery.read_query', + '{"parent":"/","query_version":3}', + '--contract-version', + '3', + ] + if selection is not None: + argv += ['--json'] + ([selection] if selection else []) + if formatter is not None: + argv += [formatter, '.' if formatter == '--jq' else '{{}}'] + assert await cli.run(cli.parser().parse_args(argv)) == 0 + captured = capsys.readouterr() + assert captured.err == '' + assert loads(captured.out.encode())['status'] == 'ok' + assert closed == [True] + if selection == '': + assert len(calls) == 1 and calls[0][0] == 'discovery.schema' + else: + operation, params, kwargs = calls[-1] + assert operation == 'discovery.read_query' and kwargs['contract_version'] == 3 + assert ('fields' in params) == (selection is not None) + if selection is not None: + assert params['fields'] == ['id', 'name'] + else: + assert len(calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'flags', + [ + ['--json', 'id', '--return-field', 'status'], + ['--jq', '.'], + ['--template', '{{/data}}'], + ['--json', '--jq', '.'], + ['--json', '--template', '{{/data}}'], + ], +) +async def test_cli_format_conflicts_refuse_before_network(flags, tmp_path, monkeypatch): + class Transport: + async def close(self): + pass + + class Client: + def __init__(self, state, transport): + pass + + async def call(self, *args, **kwargs): + raise AssertionError('invalid flags must not contact service') + + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: Transport()) + monkeypatch.setattr(cli, 'MsgClient', Client) + args = cli.parser().parse_args([ + '--config-dir', + str(tmp_path), + 'call', + 'discovery.read_query', + *flags, + ]) + with pytest.raises(Failure, match='json_query_conflict|json_fields_required'): + await cli.run(args) + + +@pytest.mark.asyncio +async def test_schema_error_preserves_json_and_nonzero_exit_without_formatter( + tmp_path, monkeypatch, capsys +): + import msg.client_output as output + from msg.core.codec import loads + from msg.core.models import OperationResult + + calls = [] + + class Transport: + async def close(self): + pass + + class Client: + def __init__(self, state, transport): + pass + + async def call(self, operation, arguments, **kwargs): + calls.append(operation) + return OperationResult( + request_id='error', + operation=operation, + status='error', + actor=None, + subject=None, + ) + + async def forbidden(*args, **kwargs): + raise AssertionError('error response must not be filtered') + + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: Transport()) + monkeypatch.setattr(cli, 'MsgClient', Client) + monkeypatch.setattr(output, 'render_jq', forbidden) + args = cli.parser().parse_args([ + '--config-dir', + str(tmp_path), + 'call', + 'discovery.read_query', + '--contract-version', + '3', + '--json', + 'id', + '--jq', + '.', + ]) + assert await cli.run(args) == 1 + captured = capsys.readouterr() + assert captured.err == '' and loads(captured.out.encode())['status'] == 'error' + assert calls == ['discovery.schema'] + + +def test_local_format_error_goes_only_to_stderr(monkeypatch, capsys): + from msg.core.codec import loads + + async def fail(args): + raise Failure('invalid_jq_query') + + monkeypatch.setattr(cli, 'run', fail) + assert cli.main(['call', 'discovery.read_query', '--json', 'id', '--jq', 'bad']) == 1 + captured = capsys.readouterr() + assert captured.out == '' + assert loads(captured.err.encode())['error']['code'] == 'invalid_jq_query' diff --git a/tests/test_client_output_formats.py b/tests/test_client_output_formats.py new file mode 100644 index 00000000..3453faf4 --- /dev/null +++ b/tests/test_client_output_formats.py @@ -0,0 +1,88 @@ +"""Bounded local formatting cannot acquire additional data or execute templates.""" + +import pytest + +from msg.core.errors import Failure + + +def test_template_uses_json_pointers_and_escapes_values(): + from msg.client_output import render_template + + data = {'data': {'items': [{'name': 'line\n\x1b[31m'}], 'a/b': {'~key': 7}}} + assert render_template('name={{/data/items/0/name}} n={{/data/a~1b/~0key}}', data) == ( + 'name="line\\n\\u001b[31m" n=7' + ) + + +@pytest.mark.parametrize( + 'template', + [ + '{{data.name}}', + '{{/missing}}', + '{{/data/__class__}}', + '{{/data/~2}}', + '{{/data', + ], +) +def test_template_rejects_non_pointer_missing_and_invalid_syntax(template): + from msg.client_output import render_template + + with pytest.raises(Failure, match='invalid_output_template'): + render_template(template, {'data': {'name': 'hello'}}) + + +def test_template_is_bounded(): + from msg.client_output import MAX_FORMAT_BYTES, render_template + + with pytest.raises(Failure, match='output_format_too_large'): + render_template('a' * 4097, {}) + with pytest.raises(Failure, match='output_format_too_large'): + render_template('{{/data}}{{/data}}', {'data': 'x' * (MAX_FORMAT_BYTES // 2)}) + + +@pytest.mark.asyncio +async def test_jq_missing_executable_is_explicit(monkeypatch): + import msg.client_output as output + + monkeypatch.setattr(output.shutil, 'which', lambda name: None) + with pytest.raises(Failure, match='jq_unavailable'): + await output.render_jq('.data', {'data': 1}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('query', ['include "secrets"; .', 'import "/tmp/private" as p; p::read']) +async def test_jq_modules_are_rejected_before_process_creation(query, monkeypatch): + import msg.client_output as output + + def forbidden(*args, **kwargs): + raise AssertionError('must not start a process') + + monkeypatch.setattr(output.asyncio, 'create_subprocess_exec', forbidden) + with pytest.raises(Failure, match='invalid_jq_query'): + await output.render_jq(query, {}) + + +@pytest.mark.asyncio +async def test_real_jq_operates_only_on_json_and_scrubbed_environment(monkeypatch): + from msg.client_output import render_jq + + monkeypatch.setenv('MSG_TEST_FORMAT_SECRET', 'must-not-appear') + assert ( + await render_jq( + '.data.items[] | .name', {'data': {'items': [{'name': 'one'}, {'name': 'two'}]}} + ) + == '"one"\n"two"\n' + ) + assert await render_jq('env.MSG_TEST_FORMAT_SECRET', {}) == 'null\n' + with pytest.raises(Failure, match='invalid_jq_query'): + await render_jq('def broken', {}) + + +@pytest.mark.asyncio +async def test_real_jq_output_limit_and_timeout(): + from msg.client_output import render_jq + + with pytest.raises(Failure, match='output_format_too_large'): + await render_jq('"x" * 1048577', {}) + with pytest.raises(Failure, match='output_format_timeout'): + await render_jq('def spin: spin; spin', {}, timeout=0.1)