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();