Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions lib/core/extensions/local_extension_installer.dart
Original file line number Diff line number Diff line change
@@ -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<Directory> Function()? extensionsDirectory,
Future<void> Function()? reloadRegistry,
}) : _extensionsDirectory =
extensionsDirectory ?? ExtensionPaths.ensureExtensionsDirectory,
_reloadRegistry = reloadRegistry ?? _defaultReloadRegistry;

static Future<void> _defaultReloadRegistry() =>
LocalExtensionRegistry.instance.reload();

final Future<Directory> Function() _extensionsDirectory;
final Future<void> Function() _reloadRegistry;

/// Reads [archiveFile], validates, extracts under `extensions/<id>/`, reloads.
Future<ExtensionManifest> 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<int>))
as Map<String, dynamic>;
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<ExtensionManifest> 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<void> _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<int>);
} else {
await Directory(targetPath).create(recursive: true);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +34,8 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont
List<ExtensionManifest> _marketplace = [];
bool _loading = true;
final Map<String, double> _installingProgress = {};
bool _sideloading = false;
String? _sideloadError;

@override
void initState() {
Expand Down Expand Up @@ -102,6 +106,39 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont
}
}

Future<void> _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;
Expand Down Expand Up @@ -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),
);
},
),
),
],
);
}

Expand Down
Loading
Loading