diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart new file mode 100644 index 00000000..257ff363 --- /dev/null +++ b/lib/core/extensions/local_extension_installer.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/extension_support.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/sandbox/sandbox_policy.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +/// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). +/// +/// Reuses the same security checks as marketplace install: path-traversal +/// rejection, SandboxPolicy, preview-only gate, and driver entry validation. +class LocalExtensionInstaller { + LocalExtensionInstaller({ + Future Function()? extensionsDirectory, + Future Function()? reloadRegistry, + }) : _extensionsDirectory = + extensionsDirectory ?? ExtensionPaths.ensureExtensionsDirectory, + _reloadRegistry = reloadRegistry ?? _defaultReloadRegistry; + + static Future _defaultReloadRegistry() => + LocalExtensionRegistry.instance.reload(); + + final Future Function() _extensionsDirectory; + final Future Function() _reloadRegistry; + + /// Reads [archiveFile], validates, extracts under `extensions//`, reloads. + Future installFromArchive( + File archiveFile, { + String? expectedSha256, + void Function(double progress)? onProgress, + }) async { + if (!await archiveFile.exists()) { + throw MarketplaceException( + 'Extension archive not found: ${archiveFile.path}', + ); + } + + onProgress?.call(0.1); + final bytes = await archiveFile.readAsBytes(); + + if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { + final actual = sha256.convert(bytes).toString().toLowerCase(); + final expected = expectedSha256.trim().toLowerCase(); + if (actual != expected) { + throw MarketplaceException( + 'SHA256 checksum mismatch. Expected: $expected, Actual: $actual. ' + 'Installation aborted.', + ); + } + } + + onProgress?.call(0.25); + final archive = ZipDecoder().decodeBytes(bytes); + if (archive.isEmpty) { + throw MarketplaceException('Extension archive is empty.'); + } + + final stripPrefix = _commonRootPrefix(archive); + final manifestEntry = _findManifestEntry(archive, stripPrefix); + if (manifestEntry == null) { + throw MarketplaceException( + 'Archive does not contain manifest.json.', + ); + } + + late final ExtensionManifest manifest; + try { + final json = jsonDecode(utf8.decode(manifestEntry.content as List)) + as Map; + manifest = ExtensionManifest.fromJson(json); + } catch (e) { + throw MarketplaceException('Invalid manifest.json: $e'); + } + + if (manifest.id.trim().isEmpty) { + throw MarketplaceException('manifest.json is missing a valid "id".'); + } + + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { + 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(' ')}', + ); + } + + onProgress?.call(0.4); + + final root = await _extensionsDirectory(); + final extDir = Directory(p.join(root.path, manifest.id)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + + try { + await _extractArchive( + archive: archive, + destDir: extDir, + stripPrefix: stripPrefix, + ); + onProgress?.call(0.85); + + ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: extDir, + ); + + // Ensure canonical manifest on disk (pretty-printed, with install metadata). + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString( + encoder.convert(manifest.toJson()), + ); + + await _reloadRegistry(); + onProgress?.call(1.0); + return ExtensionManifest.fromJson( + { + ...manifest.toJson(), + // installPath is not part of toJson; reload will set it. + }, + installPath: extDir.path, + ); + } catch (e) { + // Best-effort rollback so half-installed packages do not linger. + try { + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } catch (_) {} + if (e is MarketplaceException) rethrow; + throw MarketplaceException('Failed to install extension: $e'); + } + } + + /// Installs from a filesystem path (`.zip` / `.qext`). + Future installFromPath( + String path, { + String? expectedSha256, + void Function(double progress)? onProgress, + }) { + return installFromArchive( + File(path), + expectedSha256: expectedSha256, + onProgress: onProgress, + ); + } + + static ArchiveFile? _findManifestEntry(Archive archive, String stripPrefix) { + ArchiveFile? best; + var bestDepth = 1 << 30; + for (final file in archive) { + if (!file.isFile) continue; + var name = file.name.replaceAll('\\', '/'); + if (stripPrefix.isNotEmpty && name.startsWith(stripPrefix)) { + name = name.substring(stripPrefix.length); + } + if (name == 'manifest.json' || name.endsWith('/manifest.json')) { + final depth = '/'.allMatches(name).length; + if (depth < bestDepth) { + bestDepth = depth; + best = file; + } + } + } + return best; + } + + /// If every entry shares a single top-level folder, return `"folder/"`. + static String _commonRootPrefix(Archive archive) { + String? root; + for (final file in archive) { + var name = file.name.replaceAll('\\', '/'); + if (name.isEmpty || name == '/') continue; + // Skip macOS resource forks. + if (name.startsWith('__MACOSX/')) continue; + + final parts = name.split('/'); + if (parts.length < 2) { + return ''; // file at archive root → no strip + } + final candidate = '${parts.first}/'; + root ??= candidate; + if (root != candidate) return ''; + } + return root ?? ''; + } + + static Future _extractArchive({ + required Archive archive, + required Directory destDir, + required String stripPrefix, + }) async { + final destPath = p.normalize(destDir.path); + + for (final file in archive) { + var filename = file.name.replaceAll('\\', '/'); + if (filename.startsWith('__MACOSX/')) continue; + if (stripPrefix.isNotEmpty && filename.startsWith(stripPrefix)) { + filename = filename.substring(stripPrefix.length); + } + if (filename.isEmpty || filename == '/') continue; + + if (filename.contains('..') || + filename.startsWith('/') || + filename.startsWith('\\')) { + throw MarketplaceException( + 'Security violation: Path traversal detected in archive entry ' + '"${file.name}"', + ); + } + + final targetPath = p.normalize(p.join(destPath, filename)); + if (!targetPath.startsWith(destPath)) { + throw MarketplaceException( + 'Security violation: Extraction path out of bounds "${file.name}"', + ); + } + + if (file.isFile) { + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } + } +} diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index 61d8c0ce..e8cdcc29 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,5 +1,7 @@ +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.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/layout/window_layout.dart'; @@ -32,6 +34,8 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont List _marketplace = []; bool _loading = true; final Map _installingProgress = {}; + bool _sideloading = false; + String? _sideloadError; @override void initState() { @@ -102,6 +106,39 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont } } + Future _installFromLocalFile() async { + setState(() { + _sideloading = true; + _sideloadError = null; + }); + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya extension', + extensions: ['zip', 'qext'], + ), + ], + ); + if (file == null) return; + await LocalExtensionInstaller().installFromPath(file.path); + await LocalExtensionRegistry.instance.reload(); + if (!mounted) return; + setState(() { + _installed = LocalExtensionRegistry.instance.manifests; + _tabIndex = 0; + }); + } on MarketplaceException catch (e) { + if (mounted) setState(() => _sideloadError = e.message); + } catch (e) { + if (mounted) { + setState(() => _sideloadError = 'Failed to install extension: $e'); + } + } finally { + if (mounted) setState(() => _sideloading = false); + } + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -208,30 +245,65 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont child: material.CircularProgressIndicator(), ); } - if (_installed.isEmpty) { - return const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(32.0), - child: Text('No extensions installed yet. Explore the Marketplace tab to get started!'), + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 0), + child: material.Row( + children: [ + OutlineButton( + onPressed: _sideloading ? null : _installFromLocalFile, + child: Text( + _sideloading ? 'Installing…' : 'Install from file…', + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: const Text( + 'Install a local .zip or .qext package without the Marketplace.', + ).muted().small(), + ), + ], + ), ), - ); - } - return material.ListView.separated( - padding: const material.EdgeInsets.all(24), - itemCount: _installed.length, - separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) { - final manifest = _installed[i]; - final isInstalling = _installingProgress.containsKey(manifest.id); - final progress = _installingProgress[manifest.id]; - return ExtensionCard( - manifest: manifest, - isInstalled: true, - isInstalling: isInstalling, - installProgress: progress, - onUninstall: () => _uninstallExtension(manifest), - ); - }, + if (_sideloadError != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: Text(_sideloadError!).small(), + ), + material.Expanded( + child: _installed.isEmpty + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text( + 'No extensions installed yet. Use Install from file… ' + 'or explore the Marketplace tab.', + ), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _installed.length, + separatorBuilder: (_, __) => + const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _installed[i]; + final isInstalling = + _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: true, + isInstalling: isInstalling, + installProgress: progress, + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ), + ), + ], ); } diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 81f9768d..33937595 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/preferences_extensions_section.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -171,6 +172,8 @@ class _PreferencesDialogContentState const material.SizedBox(height: 24), const PreferencesAppearanceSection(), const material.SizedBox(height: 24), + const PreferencesExtensionsSection(), + const material.SizedBox(height: 24), const Text('SQL — PostgreSQL') .semiBold() .small() diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart new file mode 100644 index 00000000..e303d2c1 --- /dev/null +++ b/lib/features/settings/preferences_extensions_section.dart @@ -0,0 +1,135 @@ +import 'dart:async' show unawaited; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/open_directory.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Preferences section for sideloading extensions from local archives (#316). +class PreferencesExtensionsSection extends material.StatefulWidget { + const PreferencesExtensionsSection({ + super.key, + this.installer, + this.filePicker, + }); + + final LocalExtensionInstaller? installer; + + /// Injectable picker for tests. + final Future Function()? filePicker; + + @override + material.State createState() => + PreferencesExtensionsSectionState(); +} + +class PreferencesExtensionsSectionState + extends material.State { + bool _installing = false; + bool _openingFolder = false; + String? _error; + String? _success; + + LocalExtensionInstaller get _installer => + widget.installer ?? LocalExtensionInstaller(); + + Future _installFromFile() async { + setState(() { + _installing = true; + _error = null; + _success = null; + }); + try { + final picker = widget.filePicker ?? + () => openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya extension', + extensions: ['zip', 'qext'], + ), + ], + ); + final file = await picker(); + if (file == null) return; + final path = file.path; + if (path.isEmpty) return; + + final manifest = await _installer.installFromPath(path); + if (!mounted) return; + setState(() { + _success = + 'Installed "${manifest.name}" (${manifest.id}) v${manifest.version}.'; + }); + } on MarketplaceException catch (e) { + if (!mounted) return; + setState(() => _error = e.message); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Failed to install extension: $e'); + } finally { + if (mounted) setState(() => _installing = false); + } + } + + Future _openExtensionsFolder() async { + setState(() { + _openingFolder = true; + _error = null; + }); + try { + final dir = await ExtensionPaths.ensureExtensionsDirectory(); + final opened = await openDirectoryInFileManager(dir.path); + if (!mounted) return; + if (!opened) { + setState(() => _error = 'Could not open extensions folder.'); + } + } finally { + if (mounted) setState(() => _openingFolder = false); + } + } + + @override + material.Widget build(material.BuildContext context) { + final busy = _installing || _openingFolder; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').semiBold().small().foreground(), + const material.SizedBox(height: 8), + const PreferencesHint( + 'Install a local .zip or .qext package without the Marketplace. ' + 'Useful for development and offline environments.', + ), + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: busy ? null : () => unawaited(_installFromFile()), + child: Text(_installing ? 'Installing…' : 'Install from file…'), + ), + OutlineButton( + onPressed: busy ? null : () => unawaited(_openExtensionsFolder()), + child: Text( + _openingFolder ? 'Opening…' : 'Open extensions folder', + ), + ), + ], + ), + if (_error != null) ...[ + const material.SizedBox(height: 10), + Text(_error!).small().foreground(), + ], + if (_success != null) ...[ + const material.SizedBox(height: 10), + Text(_success!).muted().small(), + ], + ], + ); + } +} diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart new file mode 100644 index 00000000..a5689ccd --- /dev/null +++ b/test/core/extensions/local_extension_installer_test.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +ArchiveFile _jsonFile(String name, Map json) { + final bytes = utf8.encode(jsonEncode(json)); + return ArchiveFile(name, bytes.length, bytes); +} + +Future _writeZip(Directory dir, Archive archive, String name) async { + final bytes = ZipEncoder().encode(archive); + final file = File(p.join(dir.path, name)); + await file.writeAsBytes(bytes); + return file; +} + +void main() { + group('LocalExtensionInstaller', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_local_ext_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + ExtensionPaths.mockExtensionsDirectory = null; + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('installs theme package with root-level manifest', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'community.local-theme', + 'name': 'Local Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'main': 'theme.json', + })) + ..addFile(_jsonFile('theme.json', { + 'name': 'Local Theme', + 'type': 'dark', + 'colors': {'editor.background': '#111111'}, + })); + + final zip = await _writeZip(tempDir, archive, 'theme.zip'); + final installer = LocalExtensionInstaller(); + final installed = await installer.installFromArchive(zip); + + expect(installed.id, 'community.local-theme'); + final extDir = Directory(p.join(tempDir.path, 'community.local-theme')); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'theme.json')).exists(), isTrue); + expect( + LocalExtensionRegistry.instance.manifests.any( + (m) => m.id == 'community.local-theme', + ), + isTrue, + ); + }); + + test('strips single root folder from archive', () async { + final archive = Archive() + ..addFile(_jsonFile('my-ext/manifest.json', { + 'id': 'test.nested', + 'name': 'Nested', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })) + ..addFile(ArchiveFile('my-ext/readme.txt', 4, utf8.encode('hi\n'))); + + final zip = await _writeZip(tempDir, archive, 'nested.zip'); + await LocalExtensionInstaller().installFromArchive(zip); + + final extDir = Directory(p.join(tempDir.path, 'test.nested')); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'readme.txt')).exists(), isTrue); + expect(await Directory(p.join(extDir.path, 'my-ext')).exists(), isFalse); + }); + + test('rejects path traversal entries', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.evil', + 'name': 'Evil', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })) + ..addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); + + final zip = await _writeZip(tempDir, archive, 'evil.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive(zip), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Path traversal'), + )), + ); + expect(await Directory(p.join(tempDir.path, 'test.evil')).exists(), isFalse); + }); + + test('rejects preview database drivers without process sandbox', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.driver', + 'name': 'Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'driver.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive(zip), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview'), + )), + ); + }); + + test('rejects SHA256 mismatch', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.sha', + 'name': 'SHA', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + final zip = await _writeZip(tempDir, archive, 'sha.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive( + zip, + expectedSha256: '0' * 64, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SHA256'), + )), + ); + }); + + test('accepts matching SHA256', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.sha-ok', + 'name': 'SHA OK', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + final zipBytes = ZipEncoder().encode(archive); + final zip = File(p.join(tempDir.path, 'ok.zip')); + await zip.writeAsBytes(zipBytes); + final digest = sha256.convert(zipBytes).toString(); + + final installed = await LocalExtensionInstaller().installFromArchive( + zip, + expectedSha256: digest, + ); + expect(installed.id, 'test.sha-ok'); + }); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 53097ad6..bc68c223 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -113,6 +113,7 @@ void main() { expect(find.text('Extensions'), findsOneWidget); expect(find.text('Installed (0)'), findsOneWidget); expect(find.text('Marketplace'), findsOneWidget); + expect(find.text('Install from file…'), findsOneWidget); // Switch to Marketplace tab await tester.tap(find.text('Marketplace'));