From 4f532fc06202279b5b6b1a5dc85733308eee7ff8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 22:16:03 +0300 Subject: [PATCH 1/3] feat(sandbox): add Level-1 embedded runtime and lift driver preview gate Introduce EmbeddedSandboxRuntime with a declarative JSONC engine for SDUI and SQL helpers, QuickJS/WASM FFI stubs, ExtensionType.script, and allow installing database drivers that declare a valid process sandbox (#305). --- lib/core/extensions/extension_support.dart | 28 +- .../extensions/models/extension_type.dart | 3 + .../embedded/declarative_embedded_engine.dart | 239 ++++++++++++++++++ .../embedded/embedded_sandbox_engine.dart | 83 ++++++ .../embedded/embedded_sandbox_runtime.dart | 86 +++++++ .../embedded/native_embedded_engines.dart | 45 ++++ .../market/http_marketplace_repository.dart | 2 +- lib/core/market/marketplace_repository.dart | 2 +- .../extensions/extension_support_test.dart | 46 +++- .../embedded_sandbox_runtime_test.dart | 174 +++++++++++++ .../market/marketplace_repository_test.dart | 27 ++ 11 files changed, 725 insertions(+), 10 deletions(-) create mode 100644 lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart create mode 100644 lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart create mode 100644 lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart create mode 100644 lib/core/extensions/sandbox/embedded/native_embedded_engines.dart create mode 100644 test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart index ceb26dc6..00ab95d7 100644 --- a/lib/core/extensions/extension_support.dart +++ b/lib/core/extensions/extension_support.dart @@ -3,29 +3,43 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; /// Support matrix for locally installed / marketplace extensions. /// -/// Themes install and apply fully. Database drivers are catalog preview until -/// the dynamic plugin runtime ships (see Marketplace API roadmap). +/// Themes and Level-1 scripts install fully. Database drivers remain preview +/// listings until they declare a policy-compliant Level-2 `sandbox.engine: +/// process` block (Block E M3). class ExtensionSupport { ExtensionSupport._(); static const databaseDriverPreviewNotice = - 'Database drivers in the Marketplace are preview listings only. ' + 'Database drivers in the Marketplace are preview listings only until they ' + 'declare a policy-compliant OS process sandbox. ' 'Querya connects using built-in Dart drivers (PostgreSQL, MySQL, SQLite, ' - 'Redis, MongoDB). External driver plugins will load via the Marketplace ' - 'plugin runtime in a future release.'; + 'Redis, MongoDB). Sandboxed external drivers install when ' + '`sandbox.engine` is `process` and passes SandboxPolicy.'; static const databaseDriverMissingEntryMessage = 'Driver package is missing its main entry file. Installation aborted.'; + /// Type-level preview heuristic (drivers default to preview). + /// Prefer [isPreviewOnlyManifest] when a full manifest is available. static bool isPreviewOnly(ExtensionType type) => type == ExtensionType.databaseDriver; - static bool isPreviewOnlyManifest(ExtensionManifest manifest) => - isPreviewOnly(manifest.type); + /// Drivers without a valid Level-2 process sandbox stay preview-only. + /// Scripts / themes are always installable (subject to SandboxPolicy). + static bool isPreviewOnlyManifest(ExtensionManifest manifest) { + if (manifest.type != ExtensionType.databaseDriver) return false; + final sandbox = manifest.sandbox; + if (sandbox == null || sandbox.engine != SandboxEngine.process) { + return true; + } + return !SandboxPolicy.isAllowed(manifest); + } /// Ensures a database driver archive contains the declared [ExtensionManifest.main]. static void validateDriverPackage({ diff --git a/lib/core/extensions/models/extension_type.dart b/lib/core/extensions/models/extension_type.dart index dc6eeb0f..01226ea7 100644 --- a/lib/core/extensions/models/extension_type.dart +++ b/lib/core/extensions/models/extension_type.dart @@ -1,6 +1,9 @@ enum ExtensionType { databaseDriver('database_driver'), theme('theme'), + + /// Level-1 embedded scripts: SDUI transformers, SQL formatters, parsers. + script('script'), unknown('unknown'); final String value; diff --git a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart new file mode 100644 index 00000000..11e4a074 --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; + +/// Pure-Dart Level-1 engine for JSON / JSONC declarative modules. +/// +/// Guests declare transforms in data form — no eval, no network, no filesystem. +/// Suitable for SDUI transformers, SQL formatters, and hint generators until +/// QuickJS / WASM FFI backends are linked. +class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.quickjs; // logical Level-1 slot + + /// Alternate id when the module explicitly targets wasm-shaped JSON modules. + final bool treatAsWasm; + + DeclarativeEmbeddedEngine({this.treatAsWasm = false}); + + @override + bool get isAvailable => true; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + final source = request.source; + if (source == null || source.trim().isEmpty) { + return EmbeddedInvokeResult.failure('Module source is empty.'); + } + + late final Map module; + try { + module = _parseJsoncObject(source); + } catch (e) { + return EmbeddedInvokeResult.failure('Invalid declarative module: $e'); + } + + final kind = module['kind'] as String? ?? 'pipeline'; + switch (request.method) { + case EmbeddedInvokeMethod.sduiTransform: + return _sduiTransform(module, request.args); + case EmbeddedInvokeMethod.sqlFormat: + return _sqlFormat(module, request.args); + case EmbeddedInvokeMethod.hintsGenerate: + return _hintsGenerate(module, request.args); + case EmbeddedInvokeMethod.sqlParse: + return _sqlParse(module, request.args); + case EmbeddedInvokeMethod.invoke: + return _dispatchByKind(kind, module, request); + } + } + + EmbeddedInvokeResult _dispatchByKind( + String kind, + Map module, + EmbeddedInvokeRequest request, + ) { + switch (kind) { + case 'sdui.transform': + case 'sdui': + return _sduiTransform(module, request.args); + case 'sql.format': + case 'sql_format': + return _sqlFormat(module, request.args); + case 'hints.generate': + case 'hints': + return _hintsGenerate(module, request.args); + case 'sql.parse': + case 'sql_parse': + return _sqlParse(module, request.args); + default: + return EmbeddedInvokeResult.failure('Unknown module kind "$kind".'); + } + } + + EmbeddedInvokeResult _sduiTransform( + Map module, + Map args, + ) { + final input = args['document']; + if (input is! Map) { + return EmbeddedInvokeResult.failure( + 'sdui.transform requires args.document as a JSON object.', + ); + } + final doc = Map.from( + input.map((k, v) => MapEntry('$k', v)), + ); + + final renames = module['renameKeys']; + if (renames is Map) { + for (final entry in renames.entries) { + final from = '${entry.key}'; + final to = '${entry.value}'; + if (doc.containsKey(from)) { + doc[to] = doc.remove(from); + } + } + } + + final defaults = module['defaults']; + if (defaults is Map) { + for (final entry in defaults.entries) { + doc.putIfAbsent('${entry.key}', () => entry.value); + } + } + + final drop = module['dropKeys']; + if (drop is List) { + for (final key in drop) { + doc.remove('$key'); + } + } + + return EmbeddedInvokeResult.success(doc); + } + + EmbeddedInvokeResult _sqlFormat( + Map module, + Map args, + ) { + final sql = args['sql']; + if (sql is! String) { + return EmbeddedInvokeResult.failure('sql.format requires args.sql string.'); + } + + var out = sql.replaceAll(RegExp(r'[ \t]+'), ' ').trim(); + out = out.replaceAll(RegExp(r'\s*;\s*'), ';\n'); + final upperKeywords = module['uppercaseKeywords'] != false; + if (upperKeywords) { + const keywords = [ + 'select', + 'from', + 'where', + 'and', + 'or', + 'join', + 'left', + 'right', + 'inner', + 'outer', + 'on', + 'group', + 'by', + 'order', + 'limit', + 'insert', + 'into', + 'values', + 'update', + 'set', + 'delete', + ]; + for (final kw in keywords) { + out = out.replaceAllMapped( + RegExp('\\b$kw\\b', caseSensitive: false), + (m) => kw.toUpperCase(), + ); + } + } + return EmbeddedInvokeResult.success(out); + } + + EmbeddedInvokeResult _hintsGenerate( + Map module, + Map args, + ) { + final tables = module['tables']; + if (tables is! List) { + return EmbeddedInvokeResult.failure( + 'hints.generate module requires "tables" array.', + ); + } + final prefix = (args['prefix'] as String? ?? '').toLowerCase(); + final hints = >[]; + for (final table in tables) { + if (table is! Map) continue; + final name = '${table['name'] ?? ''}'; + if (name.isEmpty) continue; + if (prefix.isNotEmpty && !name.toLowerCase().startsWith(prefix)) { + continue; + } + hints.add({ + 'label': name, + 'kind': 'table', + 'detail': table['detail'], + }); + final columns = table['columns']; + if (columns is List) { + for (final col in columns) { + final colName = '$col'; + if (prefix.isNotEmpty && + !colName.toLowerCase().startsWith(prefix) && + !'$name.$colName'.toLowerCase().startsWith(prefix)) { + continue; + } + hints.add({ + 'label': colName, + 'kind': 'column', + 'detail': name, + }); + } + } + } + return EmbeddedInvokeResult.success(hints); + } + + EmbeddedInvokeResult _sqlParse( + Map module, + Map args, + ) { + final sql = args['sql']; + if (sql is! String) { + return EmbeddedInvokeResult.failure('sql.parse requires args.sql string.'); + } + final trimmed = sql.trim(); + final first = trimmed.split(RegExp(r'\s+')).first.toUpperCase(); + final dialect = module['dialect'] as String? ?? 'generic'; + return EmbeddedInvokeResult.success({ + 'dialect': dialect, + 'statement': first, + 'length': trimmed.length, + 'raw': trimmed, + }); + } + + /// Strips `//` and `/* */` comments then `jsonDecode`s an object. + static Map _parseJsoncObject(String source) { + final withoutBlock = source.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), ''); + final withoutLine = withoutBlock.replaceAll(RegExp(r'//[^\n]*'), ''); + final decoded = jsonDecode(withoutLine); + if (decoded is! Map) { + throw const FormatException('Module root must be a JSON object.'); + } + return decoded; + } +} diff --git a/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart b/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart new file mode 100644 index 00000000..2861b181 --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart @@ -0,0 +1,83 @@ +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; + +/// Methods exposed to Level-1 embedded guests (no network / no host FS). +enum EmbeddedInvokeMethod { + /// Transform a Server-Driven UI JSON document. + sduiTransform('sdui.transform'), + + /// Pretty-print / normalize SQL text. + sqlFormat('sql.format'), + + /// Produce editor hint / autocomplete schema fragments. + hintsGenerate('hints.generate'), + + /// Parse a dialect-specific SQL fragment into a JSON AST-ish structure. + sqlParse('sql.parse'), + + /// Generic module entry (`main` / custom). + invoke('invoke'); + + const EmbeddedInvokeMethod(this.value); + final String value; + + static EmbeddedInvokeMethod fromString(String value) { + return EmbeddedInvokeMethod.values.firstWhere( + (m) => m.value == value, + orElse: () => EmbeddedInvokeMethod.invoke, + ); + } +} + +class EmbeddedInvokeRequest { + const EmbeddedInvokeRequest({ + required this.method, + this.args = const {}, + this.source, + this.moduleId, + }); + + final EmbeddedInvokeMethod method; + final Map args; + + /// Already-loaded module source (JSON / JS / WASM bytes as base64, etc.). + final String? source; + final String? moduleId; +} + +class EmbeddedInvokeResult { + const EmbeddedInvokeResult({ + required this.ok, + this.value, + this.error, + }); + + final bool ok; + final Object? value; + final String? error; + + factory EmbeddedInvokeResult.success(Object? value) => + EmbeddedInvokeResult(ok: true, value: value); + + factory EmbeddedInvokeResult.failure(String error) => + EmbeddedInvokeResult(ok: false, error: error); +} + +/// In-process Level-1 sandbox engine (WASM / QuickJS / declarative). +abstract class EmbeddedSandboxEngine { + SandboxEngine get engine; + + /// Whether this build can actually execute guest code. + bool get isAvailable; + + Future invoke(EmbeddedInvokeRequest request); + + Future dispose(); +} + +class EmbeddedEngineUnavailableException implements Exception { + EmbeddedEngineUnavailableException(this.message); + final String message; + + @override + String toString() => 'EmbeddedEngineUnavailableException: $message'; +} diff --git a/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart b/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart new file mode 100644 index 00000000..4518887f --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart @@ -0,0 +1,86 @@ +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/declarative_embedded_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/native_embedded_engines.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; + +/// Dispatches Level-1 embedded sandbox invocations (Block E M3). +/// +/// Enforces `network: none` / scratch-only policy for embedded engines and +/// prefers the declarative JSONC engine when native QuickJS/WASM are not linked. +class EmbeddedSandboxRuntime { + EmbeddedSandboxRuntime({ + EmbeddedSandboxEngine? declarative, + EmbeddedSandboxEngine? quickJs, + EmbeddedSandboxEngine? wasm, + }) : _declarative = declarative ?? DeclarativeEmbeddedEngine(), + _quickJs = quickJs ?? QuickJsEmbeddedEngine(), + _wasm = wasm ?? WasmEmbeddedEngine(); + + final EmbeddedSandboxEngine _declarative; + final EmbeddedSandboxEngine _quickJs; + final EmbeddedSandboxEngine _wasm; + + /// Runs [request] under the engine declared by [manifest] (or [engineOverride]). + Future invoke({ + required EmbeddedInvokeRequest request, + ExtensionManifest? manifest, + SandboxEngine? engineOverride, + }) async { + if (manifest != null) { + final violations = SandboxPolicy.validate(manifest); + if (violations.isNotEmpty) { + return EmbeddedInvokeResult.failure( + 'Sandbox policy violations: ${violations.join(' ')}', + ); + } + final sandbox = manifest.sandbox; + if (sandbox != null && + sandbox.network.mode != NetworkPermissionMode.none) { + return EmbeddedInvokeResult.failure( + 'Embedded runtime forbids network access ' + '(got ${sandbox.network.mode.value}).', + ); + } + } + + final engineId = engineOverride ?? + manifest?.sandbox?.engine ?? + SandboxEngine.quickjs; + + if (engineId == SandboxEngine.process) { + return EmbeddedInvokeResult.failure( + 'Embedded runtime cannot execute OS-process engines; ' + 'use SandboxProcessRunner instead.', + ); + } + + final engine = _resolve(engineId); + try { + return await engine.invoke(request); + } on EmbeddedEngineUnavailableException { + // Fall back to declarative JSONC modules for Level-1 workloads. + if (identical(engine, _declarative)) rethrow; + return _declarative.invoke(request); + } + } + + EmbeddedSandboxEngine _resolve(SandboxEngine id) { + switch (id) { + case SandboxEngine.wasm: + return _wasm.isAvailable ? _wasm : _declarative; + case SandboxEngine.quickjs: + return _quickJs.isAvailable ? _quickJs : _declarative; + case SandboxEngine.process: + case SandboxEngine.unknown: + return _declarative; + } + } + + Future dispose() async { + await _declarative.dispose(); + await _quickJs.dispose(); + await _wasm.dispose(); + } +} diff --git a/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart b/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart new file mode 100644 index 00000000..58b3153e --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart @@ -0,0 +1,45 @@ +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; + +/// Placeholder QuickJS FFI backend (Level 1). +/// +/// Native `flutter_qjs` / QuickJS linkage lands in a follow-up; until then the +/// [DeclarativeEmbeddedEngine] serves JSONC modules on the QuickJS slot. +class QuickJsEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.quickjs; + + @override + bool get isAvailable => false; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + throw EmbeddedEngineUnavailableException( + 'QuickJS FFI runtime is not linked in this build. ' + 'Use a declarative JSONC module or wait for the native QuickJS backend.', + ); + } +} + +/// Placeholder WASI / wasmtime FFI backend (Level 1). +class WasmEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.wasm; + + @override + bool get isAvailable => false; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + throw EmbeddedEngineUnavailableException( + 'WASM/WASI runtime (wasmtime) is not linked in this build. ' + 'Use a declarative JSONC module or wait for the native WASM backend.', + ); + } +} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 068f4510..59f47314 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -99,7 +99,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ExtensionManifest manifest, { void Function(double)? onProgress, }) async { - if (ExtensionSupport.isPreviewOnly(manifest.type)) { + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index e4566844..94481f9a 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -185,7 +185,7 @@ class MockMarketplaceRepository implements MarketplaceRepository { ExtensionManifest manifest, { void Function(double)? onProgress, } ) async { - if (ExtensionSupport.isPreviewOnly(manifest.type)) { + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart index 0491d0ad..71371b4e 100644 --- a/test/core/extensions/extension_support_test.dart +++ b/test/core/extensions/extension_support_test.dart @@ -5,16 +5,60 @@ import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; void main() { group('ExtensionSupport', () { - test('marks database drivers as preview-only', () { + test('marks bare database drivers as preview-only', () { expect( ExtensionSupport.isPreviewOnly(ExtensionType.databaseDriver), isTrue, ); expect(ExtensionSupport.isPreviewOnly(ExtensionType.theme), isFalse); + expect(ExtensionSupport.isPreviewOnly(ExtensionType.script), isFalse); + }); + + test('lifts preview for drivers with valid process sandbox', () { + const preview = ExtensionManifest( + id: 'test.driver', + name: 'Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '*'}, + ); + expect(ExtensionSupport.isPreviewOnlyManifest(preview), isTrue); + + final ready = ExtensionManifest( + id: 'test.driver', + name: 'Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: const {'querya_desktop': '*'}, + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); + expect(ExtensionSupport.isPreviewOnlyManifest(ready), isFalse); + }); + + test('scripts are never preview-only', () { + const script = ExtensionManifest( + id: 'test.sql-formatter', + name: 'SQL Formatter', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.script, + engines: {'querya_desktop': '*'}, + sandbox: SandboxCapabilities(engine: SandboxEngine.quickjs), + ); + expect(ExtensionSupport.isPreviewOnlyManifest(script), isFalse); }); test('validateDriverPackage requires main entry file', () async { diff --git a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart new file mode 100644 index 00000000..db6bf48f --- /dev/null +++ b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/declarative_embedded_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/native_embedded_engines.dart'; + +void main() { + group('DeclarativeEmbeddedEngine', () { + late DeclarativeEmbeddedEngine engine; + + setUp(() { + engine = DeclarativeEmbeddedEngine(); + }); + + test('transforms SDUI documents with rename/defaults/drop', () async { + const source = ''' +{ + "kind": "sdui.transform", + // rename title → heading + "renameKeys": { "title": "heading" }, + "defaults": { "version": 1 }, + "dropKeys": ["debug"] +} +'''; + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sduiTransform, + source: source, + args: { + 'document': { + 'title': 'Hello', + 'debug': true, + 'body': 'x', + }, + }, + ), + ); + + expect(result.ok, isTrue); + final doc = result.value! as Map; + expect(doc['heading'], 'Hello'); + expect(doc['version'], 1); + expect(doc.containsKey('title'), isFalse); + expect(doc.containsKey('debug'), isFalse); + }); + + test('formats SQL and uppercases keywords', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select * from users where id=1;'}, + ), + ); + expect(result.ok, isTrue); + expect(result.value, 'SELECT * FROM users WHERE id=1;\n'); + }); + + test('generates hints filtered by prefix', () async { + const source = ''' +{ + "kind": "hints.generate", + "tables": [ + { "name": "users", "columns": ["id", "email"] }, + { "name": "orders", "columns": ["id"] } + ] +} +'''; + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.hintsGenerate, + source: source, + args: {'prefix': 'us'}, + ), + ); + expect(result.ok, isTrue); + final hints = (result.value! as List).cast(); + expect(hints.any((h) => h['label'] == 'users'), isTrue); + expect(hints.any((h) => h['label'] == 'orders'), isFalse); + }); + + test('parses SQL statement kind', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: '{"kind":"sql.parse","dialect":"postgresql"}', + args: {'sql': ' update users set x=1 '}, + ), + ); + expect(result.ok, isTrue); + final ast = result.value! as Map; + expect(ast['statement'], 'UPDATE'); + expect(ast['dialect'], 'postgresql'); + }); + }); + + group('EmbeddedSandboxRuntime', () { + test('falls back to declarative when QuickJS FFI is unavailable', () async { + final runtime = EmbeddedSandboxRuntime( + quickJs: QuickJsEmbeddedEngine(), + ); + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select 1'}, + ), + engineOverride: SandboxEngine.quickjs, + ); + expect(result.ok, isTrue); + expect(result.value, contains('SELECT')); + }); + + test('rejects embedded invoke when network permission is requested', () async { + final runtime = EmbeddedSandboxRuntime(); + final manifest = ExtensionManifest( + id: 'bad.script', + name: 'Bad', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.script, + engines: const {'querya_desktop': '*'}, + sandbox: SandboxCapabilities.fromJson(const { + 'engine': 'quickjs', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }), + ); + + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select 1'}, + ), + manifest: manifest, + ); + expect(result.ok, isFalse); + expect(result.error, contains('Sandbox policy')); + }); + + test('rejects process engine on embedded runtime', () async { + final runtime = EmbeddedSandboxRuntime(); + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.invoke, + source: '{}', + ), + engineOverride: SandboxEngine.process, + ); + expect(result.ok, isFalse); + expect(result.error, contains('OS-process')); + }); + }); + + group('Native embedded engines', () { + test('report unavailable and throw on invoke', () async { + final qjs = QuickJsEmbeddedEngine(); + final wasm = WasmEmbeddedEngine(); + expect(qjs.isAvailable, isFalse); + expect(wasm.isAvailable, isFalse); + await expectLater( + qjs.invoke(const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.invoke, + )), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index 82e06f99..9b127bc2 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; void main() { @@ -100,6 +101,32 @@ void main() { ); }); + test('install accepts database driver with valid process sandbox', () async { + final repo = MockMarketplaceRepository(); + final manifest = ExtensionManifest( + id: 'test.sandboxed-driver', + name: 'Sandboxed Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: const {'querya_desktop': '*'}, + main: 'bin/driver', + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); + + await repo.install(manifest); + + final extDir = Directory(p.join(tempDir.path, manifest.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + }); + test('install theme creates theme.json in extension directory', () async { final repo = MockMarketplaceRepository(); final trending = await repo.getTrending(); From 704d788d1f5b06d0b3019574381af645d86b99a6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 22:20:27 +0300 Subject: [PATCH 2/3] fix(sandbox): prefer const constructors in #305 tests --- test/core/extensions/extension_support_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart index 71371b4e..c0681dbb 100644 --- a/test/core/extensions/extension_support_test.dart +++ b/test/core/extensions/extension_support_test.dart @@ -30,14 +30,14 @@ void main() { ); expect(ExtensionSupport.isPreviewOnlyManifest(preview), isTrue); - final ready = ExtensionManifest( + const ready = ExtensionManifest( id: 'test.driver', name: 'Driver', version: '1.0.0', publisher: 'Test', type: ExtensionType.databaseDriver, - engines: const {'querya_desktop': '*'}, - sandbox: const SandboxCapabilities( + engines: {'querya_desktop': '*'}, + sandbox: SandboxCapabilities( engine: SandboxEngine.process, network: NetworkPermission( mode: NetworkPermissionMode.connectionHostOnly, From 3b473190a0535c18a46a481b730b5d90fe902dde Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 22:20:42 +0300 Subject: [PATCH 3/3] fix(sandbox): prefer const ExtensionManifest in marketplace test --- test/core/market/marketplace_repository_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index 9b127bc2..db9e14d2 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -103,15 +103,15 @@ void main() { test('install accepts database driver with valid process sandbox', () async { final repo = MockMarketplaceRepository(); - final manifest = ExtensionManifest( + const manifest = ExtensionManifest( id: 'test.sandboxed-driver', name: 'Sandboxed Driver', version: '1.0.0', publisher: 'Test', type: ExtensionType.databaseDriver, - engines: const {'querya_desktop': '*'}, + engines: {'querya_desktop': '*'}, main: 'bin/driver', - sandbox: const SandboxCapabilities( + sandbox: SandboxCapabilities( engine: SandboxEngine.process, network: NetworkPermission( mode: NetworkPermissionMode.connectionHostOnly,