From 475b995fa715845665676a3c223b95b7ec555b82 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 21:36:13 +0300 Subject: [PATCH] feat(sandbox): parse and validate sandbox capabilities from extension manifest Adds SandboxCapabilities, NetworkPermission, FilesystemPermission and ResourceLimits models parsed from the manifest "sandbox" block, plus SandboxPolicy validation enforced in LocalExtensionRegistry loading and marketplace install flows (issue #301, Block E M1). --- .../extensions/local_extension_registry.dart | 15 +- .../extensions/models/extension_manifest.dart | 10 + .../models/sandbox_capabilities.dart | 183 ++++++++++++++++++ .../extensions/sandbox/sandbox_policy.dart | 92 +++++++++ .../market/http_marketplace_repository.dart | 9 + lib/core/market/marketplace_repository.dart | 11 +- .../local_extension_registry_test.dart | 44 +++++ .../models/sandbox_capabilities_test.dart | 123 ++++++++++++ .../sandbox/sandbox_policy_test.dart | 118 +++++++++++ .../market/marketplace_repository_test.dart | 28 +++ 10 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 lib/core/extensions/models/sandbox_capabilities.dart create mode 100644 lib/core/extensions/sandbox/sandbox_policy.dart create mode 100644 test/core/extensions/models/sandbox_capabilities_test.dart create mode 100644 test/core/extensions/sandbox/sandbox_policy_test.dart diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 95b69c1d..aa3a1034 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -1,10 +1,12 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'extension_paths.dart'; import 'models/extension_manifest.dart'; +import 'sandbox/sandbox_policy.dart'; /// Scans the local filesystem for extensions and loads their manifests. class LocalExtensionRegistry { @@ -56,10 +58,19 @@ class LocalExtensionRegistry { json, installPath: entity.path, ); + final violations = SandboxPolicy.validate(manifest); + if (violations.isNotEmpty) { + debugPrint( + 'LocalExtensionRegistry: skipped "${manifest.id}" — ' + 'sandbox policy violations: ${violations.join(' ')}', + ); + continue; + } loadedManifests.add(manifest); } catch (e) { - // Log or ignore invalid manifests - // In the future, we could report these to an error logging service + debugPrint( + 'LocalExtensionRegistry: invalid manifest in ${entity.path} ($e)', + ); } } } diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index efd9a6e1..d5a060c6 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,5 +1,6 @@ import '../../theme/theme_definition.dart'; import 'extension_type.dart'; +import 'sandbox_capabilities.dart'; class ExtensionManifest { static const typeTheme = ExtensionType.theme; @@ -22,6 +23,10 @@ class ExtensionManifest { final String? preview; final List tags; + /// Sandbox requirements declared by the extension (Block E). Null when the + /// manifest has no `sandbox` block (e.g. plain themes). + final SandboxCapabilities? sandbox; + const ExtensionManifest({ required this.id, required this.name, @@ -40,6 +45,7 @@ class ExtensionManifest { this.license, this.preview, this.tags = const [], + this.sandbox, }); /// Maps a registry [ThemeDefinition] into marketplace field names. @@ -89,6 +95,9 @@ class ExtensionManifest { license: json['license'] as String?, preview: json['preview'] as String?, tags: List.from(json['tags'] as List? ?? []), + sandbox: json['sandbox'] is Map + ? SandboxCapabilities.fromJson(json['sandbox'] as Map) + : null, ); } @@ -110,6 +119,7 @@ class ExtensionManifest { if (license != null) 'license': license, if (preview != null) 'preview': preview, if (tags.isNotEmpty) 'tags': tags, + if (sandbox != null) 'sandbox': sandbox!.toJson(), }; } } diff --git a/lib/core/extensions/models/sandbox_capabilities.dart b/lib/core/extensions/models/sandbox_capabilities.dart new file mode 100644 index 00000000..3037e839 --- /dev/null +++ b/lib/core/extensions/models/sandbox_capabilities.dart @@ -0,0 +1,183 @@ +/// Sandbox declaration parsed from the `sandbox` block of an extension +/// manifest (Block E — Sandbox Runtime). +/// +/// Example manifest fragment: +/// ```json +/// "sandbox": { +/// "engine": "process", +/// "permissions": { +/// "network": { "mode": "connection_host_only", "allow_ssl": true }, +/// "filesystem": { "scratch_mb": 100, "access": "scratch_only" }, +/// "resources": { "memory_mb": 256, "max_open_files": 64 } +/// } +/// } +/// ``` +library; + +/// Execution engine requested by the extension. +enum SandboxEngine { + /// Level 2 — managed OS process (bwrap / sandbox-exec / AppContainer). + process('process'), + + /// Level 1 — embedded WASM runtime inside the host process. + wasm('wasm'), + + /// Level 1 — embedded QuickJS runtime inside the host process. + quickjs('quickjs'), + + unknown('unknown'); + + const SandboxEngine(this.value); + final String value; + + static SandboxEngine fromString(String? value) { + if (value == null) return SandboxEngine.unknown; + return SandboxEngine.values.firstWhere( + (e) => e.value == value, + orElse: () => SandboxEngine.unknown, + ); + } + + /// Embedded engines run in-process with full memory isolation. + bool get isEmbedded => this == SandboxEngine.wasm || this == SandboxEngine.quickjs; +} + +/// Network access mode requested by the extension. +enum NetworkPermissionMode { + /// No sockets at all (themes, parsers, SDUI transformers). + none('none'), + + /// Outgoing TCP/TLS only to the user-configured `connection.host:port`. + connectionHostOnly('connection_host_only'), + + unknown('unknown'); + + const NetworkPermissionMode(this.value); + final String value; + + static NetworkPermissionMode fromString(String? value) { + if (value == null) return NetworkPermissionMode.none; + return NetworkPermissionMode.values.firstWhere( + (e) => e.value == value, + orElse: () => NetworkPermissionMode.unknown, + ); + } +} + +class NetworkPermission { + const NetworkPermission({ + this.mode = NetworkPermissionMode.none, + this.allowSsl = false, + }); + + final NetworkPermissionMode mode; + final bool allowSsl; + + factory NetworkPermission.fromJson(Map json) { + return NetworkPermission( + mode: NetworkPermissionMode.fromString(json['mode'] as String?), + allowSsl: json['allow_ssl'] as bool? ?? false, + ); + } + + Map toJson() => { + 'mode': mode.value, + 'allow_ssl': allowSsl, + }; +} + +class FilesystemPermission { + const FilesystemPermission({ + this.scratchMb = defaultScratchMb, + this.access = scratchOnlyAccess, + }); + + static const scratchOnlyAccess = 'scratch_only'; + static const defaultScratchMb = 100; + + /// Quota for the read-write scratch directory, in megabytes. + final int scratchMb; + + /// Filesystem access scope. Only [scratchOnlyAccess] is supported. + final String access; + + factory FilesystemPermission.fromJson(Map json) { + return FilesystemPermission( + scratchMb: json['scratch_mb'] as int? ?? defaultScratchMb, + access: json['access'] as String? ?? scratchOnlyAccess, + ); + } + + Map toJson() => { + 'scratch_mb': scratchMb, + 'access': access, + }; +} + +class ResourceLimits { + const ResourceLimits({ + this.memoryMb = defaultMemoryMb, + this.maxOpenFiles = defaultMaxOpenFiles, + }); + + static const defaultMemoryMb = 256; + static const defaultMaxOpenFiles = 64; + + /// Hard RAM limit for the plugin process, in megabytes. + final int memoryMb; + + /// Maximum number of open file descriptors (`ulimit -n`). + final int maxOpenFiles; + + factory ResourceLimits.fromJson(Map json) { + return ResourceLimits( + memoryMb: json['memory_mb'] as int? ?? defaultMemoryMb, + maxOpenFiles: json['max_open_files'] as int? ?? defaultMaxOpenFiles, + ); + } + + Map toJson() => { + 'memory_mb': memoryMb, + 'max_open_files': maxOpenFiles, + }; +} + +/// Full sandbox requirements block declared by an extension. +class SandboxCapabilities { + const SandboxCapabilities({ + required this.engine, + this.network = const NetworkPermission(), + this.filesystem = const FilesystemPermission(), + this.resources = const ResourceLimits(), + }); + + final SandboxEngine engine; + final NetworkPermission network; + final FilesystemPermission filesystem; + final ResourceLimits resources; + + factory SandboxCapabilities.fromJson(Map json) { + final permissions = json['permissions'] as Map? ?? const {}; + return SandboxCapabilities( + engine: SandboxEngine.fromString(json['engine'] as String?), + network: permissions['network'] is Map + ? NetworkPermission.fromJson(permissions['network'] as Map) + : const NetworkPermission(), + filesystem: permissions['filesystem'] is Map + ? FilesystemPermission.fromJson(permissions['filesystem'] as Map) + : const FilesystemPermission(), + resources: permissions['resources'] is Map + ? ResourceLimits.fromJson(permissions['resources'] as Map) + : const ResourceLimits(), + ); + } + + Map toJson() => { + 'engine': engine.value, + 'permissions': { + 'network': network.toJson(), + 'filesystem': filesystem.toJson(), + 'resources': resources.toJson(), + }, + }; +} diff --git a/lib/core/extensions/sandbox/sandbox_policy.dart b/lib/core/extensions/sandbox/sandbox_policy.dart new file mode 100644 index 00000000..8305a14d --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_policy.dart @@ -0,0 +1,92 @@ +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'; + +/// Security policy limits for sandboxed extensions (Block E, section 3). +/// +/// Validates that the `sandbox` block declared in a manifest does not request +/// more than the allowed policy for its [ExtensionType]. Used by +/// `LocalExtensionRegistry` when loading and `MarketplaceRepository.install()` +/// before registration. +class SandboxPolicy { + SandboxPolicy._(); + + /// Hard scratch directory quota, MB. + static const maxScratchMb = 100; + + /// Hard RAM ceiling for heavy OLAP drivers, MB. + static const maxMemoryMb = 512; + + /// Hard file descriptor ceiling. + static const maxOpenFiles = 64; + + /// Returns a list of policy violations; empty means the manifest is allowed. + static List validate(ExtensionManifest manifest) { + final sandbox = manifest.sandbox; + if (sandbox == null) return const []; + + final errors = []; + final type = manifest.type; + + if (sandbox.engine == SandboxEngine.unknown) { + errors.add('Unknown sandbox engine.'); + } + + if (sandbox.network.mode == NetworkPermissionMode.unknown) { + errors.add('Unknown network permission mode.'); + } + + // OS process sandbox (Level 2) is reserved for database drivers. + if (sandbox.engine == SandboxEngine.process && + type != ExtensionType.databaseDriver) { + errors.add( + 'Sandbox engine "process" is only allowed for database drivers.', + ); + } + + // Network sockets are only allowed for database drivers, and only to the + // user-configured connection host. + if (sandbox.network.mode == NetworkPermissionMode.connectionHostOnly && + type != ExtensionType.databaseDriver) { + errors.add( + 'Network access is not allowed for extensions of type "${type.value}".', + ); + } + + if (sandbox.filesystem.access != FilesystemPermission.scratchOnlyAccess) { + errors.add( + 'Filesystem access "${sandbox.filesystem.access}" is not allowed; ' + 'only "${FilesystemPermission.scratchOnlyAccess}" is supported.', + ); + } + + if (sandbox.filesystem.scratchMb <= 0 || + sandbox.filesystem.scratchMb > maxScratchMb) { + errors.add( + 'Scratch quota ${sandbox.filesystem.scratchMb} MB exceeds the ' + '$maxScratchMb MB limit.', + ); + } + + if (sandbox.resources.memoryMb <= 0 || + sandbox.resources.memoryMb > maxMemoryMb) { + errors.add( + 'Memory limit ${sandbox.resources.memoryMb} MB exceeds the ' + '$maxMemoryMb MB ceiling.', + ); + } + + if (sandbox.resources.maxOpenFiles <= 0 || + sandbox.resources.maxOpenFiles > maxOpenFiles) { + errors.add( + 'File descriptor limit ${sandbox.resources.maxOpenFiles} exceeds ' + 'the $maxOpenFiles ceiling.', + ); + } + + return errors; + } + + static bool isAllowed(ExtensionManifest manifest) => + validate(manifest).isEmpty; +} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 068c85ef..068f4510 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.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'; @@ -102,6 +103,14 @@ class HttpMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final sandboxViolations = SandboxPolicy.validate(manifest); + if (sandboxViolations.isNotEmpty) { + throw MarketplaceException( + 'Extension "${manifest.id}" requests sandbox permissions beyond the ' + 'security policy: ${sandboxViolations.join(' ')}', + ); + } + final downloadUrl = manifest.downloadUrl; if (downloadUrl == null || downloadUrl.trim().isEmpty) { throw MarketplaceException('Extension manifest is missing downloadUrl'); diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 65dd2015..e4566844 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.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'; @@ -183,11 +184,19 @@ class MockMarketplaceRepository implements MarketplaceRepository { Future install( ExtensionManifest manifest, { void Function(double)? onProgress, - }) async { + } ) async { if (ExtensionSupport.isPreviewOnly(manifest.type)) { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final sandboxViolations = SandboxPolicy.validate(manifest); + if (sandboxViolations.isNotEmpty) { + throw MarketplaceException( + 'Extension "${manifest.id}" requests sandbox permissions beyond the ' + 'security policy: ${sandboxViolations.join(' ')}', + ); + } + // Simulate download & verification progress for (int i = 1; i <= 10; i++) { await Future.delayed(const Duration(milliseconds: 100)); diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart index f5d88037..323865de 100644 --- a/test/core/extensions/local_extension_registry_test.dart +++ b/test/core/extensions/local_extension_registry_test.dart @@ -71,6 +71,50 @@ void main() { expect(LocalExtensionRegistry.instance.manifests, isEmpty); }); + test('skips extensions violating sandbox policy', () async { + final badDir = Directory(p.join(tempDir.path, 'bad_sandbox')); + await badDir.create(); + await File(p.join(badDir.path, 'manifest.json')).writeAsString(jsonEncode({ + 'id': 'test.bad-sandbox', + 'name': 'Bad Sandbox Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }, + })); + + final goodDir = Directory(p.join(tempDir.path, 'good_sandbox')); + await goodDir.create(); + await File(p.join(goodDir.path, 'manifest.json')).writeAsString(jsonEncode({ + 'id': 'test.good-sandbox', + 'name': 'Good Sandbox Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + })); + + await LocalExtensionRegistry.instance.reload(); + final manifests = LocalExtensionRegistry.instance.manifests; + + expect(manifests.length, 1); + expect(manifests.first.id, 'test.good-sandbox'); + }); + test('returns cached manifests on subsequent load calls', () async { final extDir = Directory(p.join(tempDir.path, 'ext3')); await extDir.create(); diff --git a/test/core/extensions/models/sandbox_capabilities_test.dart b/test/core/extensions/models/sandbox_capabilities_test.dart new file mode 100644 index 00000000..8ffb337c --- /dev/null +++ b/test/core/extensions/models/sandbox_capabilities_test.dart @@ -0,0 +1,123 @@ +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'; + +void main() { + group('SandboxCapabilities', () { + test('parses full sandbox block from Block E spec example', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }); + + expect(capabilities.engine, SandboxEngine.process); + expect(capabilities.network.mode, NetworkPermissionMode.connectionHostOnly); + expect(capabilities.network.allowSsl, isTrue); + expect(capabilities.filesystem.scratchMb, 100); + expect(capabilities.filesystem.access, 'scratch_only'); + expect(capabilities.resources.memoryMb, 256); + expect(capabilities.resources.maxOpenFiles, 64); + }); + + test('applies safe defaults when permissions are omitted', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'wasm', + }); + + expect(capabilities.engine, SandboxEngine.wasm); + expect(capabilities.engine.isEmbedded, isTrue); + expect(capabilities.network.mode, NetworkPermissionMode.none); + expect(capabilities.network.allowSsl, isFalse); + expect(capabilities.filesystem.scratchMb, + FilesystemPermission.defaultScratchMb); + expect(capabilities.resources.memoryMb, ResourceLimits.defaultMemoryMb); + expect(capabilities.resources.maxOpenFiles, + ResourceLimits.defaultMaxOpenFiles); + }); + + test('maps unknown engine and network mode to unknown', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'jvm', + 'permissions': { + 'network': {'mode': 'full_internet'}, + }, + }); + + expect(capabilities.engine, SandboxEngine.unknown); + expect(capabilities.network.mode, NetworkPermissionMode.unknown); + }); + + test('toJson round-trips through fromJson', () { + const original = SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + filesystem: FilesystemPermission(scratchMb: 50), + resources: ResourceLimits(memoryMb: 512, maxOpenFiles: 32), + ); + + final restored = SandboxCapabilities.fromJson(original.toJson()); + + expect(restored.engine, original.engine); + expect(restored.network.mode, original.network.mode); + expect(restored.network.allowSsl, original.network.allowSsl); + expect(restored.filesystem.scratchMb, original.filesystem.scratchMb); + expect(restored.resources.memoryMb, original.resources.memoryMb); + expect(restored.resources.maxOpenFiles, original.resources.maxOpenFiles); + }); + }); + + group('ExtensionManifest sandbox integration', () { + test('fromJson parses sandbox block and toJson serializes it back', () { + final manifest = ExtensionManifest.fromJson(const { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Driver', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^0.5.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + }); + + expect(manifest.type, ExtensionType.databaseDriver); + expect(manifest.sandbox, isNotNull); + expect(manifest.sandbox!.engine, SandboxEngine.process); + + final json = manifest.toJson(); + expect(json['sandbox'], isA>()); + final restored = ExtensionManifest.fromJson(json); + expect(restored.sandbox!.network.mode, + NetworkPermissionMode.connectionHostOnly); + expect(restored.sandbox!.resources.memoryMb, 256); + }); + + test('manifest without sandbox block keeps sandbox null and omits key', () { + final manifest = ExtensionManifest.fromJson(const { + 'id': 'community.nord-theme', + 'name': 'Nord Theme', + 'version': '0.8.2', + 'publisher': 'Community', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + }); + + expect(manifest.sandbox, isNull); + expect(manifest.toJson().containsKey('sandbox'), isFalse); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_policy_test.dart b/test/core/extensions/sandbox/sandbox_policy_test.dart new file mode 100644 index 00000000..c1dd53b9 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_policy_test.dart @@ -0,0 +1,118 @@ +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/sandbox_policy.dart'; + +ExtensionManifest _manifest({ + ExtensionType type = ExtensionType.databaseDriver, + SandboxCapabilities? sandbox, +}) { + return ExtensionManifest( + id: 'test.ext', + name: 'Test Extension', + version: '1.0.0', + publisher: 'Test', + type: type, + engines: const {'querya_desktop': '*'}, + sandbox: sandbox, + ); +} + +void main() { + group('SandboxPolicy', () { + test('manifest without sandbox block is allowed', () { + expect(SandboxPolicy.validate(_manifest(sandbox: null)), isEmpty); + expect(SandboxPolicy.isAllowed(_manifest(sandbox: null)), isTrue); + }); + + test('allows compliant database driver declaration', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + + test('rejects process engine for non-driver extensions', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities(engine: SandboxEngine.process), + ); + expect( + SandboxPolicy.validate(manifest), + contains(contains('only allowed for database drivers')), + ); + }); + + test('rejects network access for non-driver extensions', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities( + engine: SandboxEngine.quickjs, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + ), + ), + ); + expect( + SandboxPolicy.validate(manifest), + contains(contains('Network access is not allowed')), + ); + }); + + test('rejects quota overruns', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + filesystem: FilesystemPermission(scratchMb: 500), + resources: ResourceLimits(memoryMb: 4096, maxOpenFiles: 1024), + ), + ); + final errors = SandboxPolicy.validate(manifest); + expect(errors, hasLength(3)); + expect(errors, contains(contains('Scratch quota'))); + expect(errors, contains(contains('Memory limit'))); + expect(errors, contains(contains('File descriptor limit'))); + }); + + test('rejects non-scratch filesystem access and unknown values', () { + final manifest = _manifest( + sandbox: SandboxCapabilities.fromJson(const { + 'engine': 'jvm', + 'permissions': { + 'network': {'mode': 'full_internet'}, + 'filesystem': {'access': 'full_disk'}, + }, + }), + ); + final errors = SandboxPolicy.validate(manifest); + expect(errors, contains(contains('Unknown sandbox engine'))); + expect(errors, contains(contains('Unknown network permission mode'))); + expect(errors, contains(contains('Filesystem access "full_disk"'))); + }); + + test('allows embedded engine with no permissions for themes', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities(engine: SandboxEngine.wasm), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + + test('allows 512 MB memory for heavy OLAP drivers', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + resources: ResourceLimits(memoryMb: 512), + ), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index d2ab9185..82e06f99 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -72,6 +72,34 @@ void main() { ); }); + test('install rejects theme requesting excessive sandbox permissions', + () async { + final repo = MockMarketplaceRepository(); + final manifest = ExtensionManifest.fromJson(const { + 'id': 'test.greedy-theme', + 'name': 'Greedy Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }, + }); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('sandbox permissions beyond the security policy'), + )), + ); + }); + test('install theme creates theme.json in extension directory', () async { final repo = MockMarketplaceRepository(); final trending = await repo.getTrending();