diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index 1d8378f8..d04a4cd0 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -82,7 +82,7 @@ List validateExtensionManifest( if (range is! String) { warning('engines.querya_desktop', 'no Querya Desktop constraint declared'); } else { - final ok = _satisfies(profile.targetVersion, range); + final ok = engineRangeAllows(profile.targetVersion, range); if (ok == null) { warning('engines.querya_desktop', 'unsupported range "$range"; use ^x.y.z or >=x.y.z'); @@ -147,7 +147,7 @@ final _semver = RegExp(r'^\d+\.\d+\.\d+(?:[+-][0-9A-Za-z.+-]+)?$'); /// `true` / `false` when [range] (`^x.y.z` or `>=x.y.z`) can be evaluated, /// `null` when its syntax is not understood. -bool? _satisfies(HostVersion host, String range) { +bool? engineRangeAllows(HostVersion host, String range) { final r = range.trim(); try { if (r.startsWith('>=')) { diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart new file mode 100644 index 00000000..47dd7a63 --- /dev/null +++ b/lib/core/market/static_registry_repository.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:querya_desktop/core/extensions/harness/extension_manifest_validator.dart'; +import 'package:querya_desktop/core/extensions/harness/extension_protocol_profile.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; + +import 'marketplace_repository.dart'; + +/// The marketplace as a static file: `index.json` published by the extension +/// registry (`QueryaHub/extensions`, on GitHub Pages) with no server of ours. +/// +/// The index is built by the registry's CI from the extensions' own release +/// archives, so every entry already carries the archive's SHA-256. Installing +/// is the safe pipeline of [HttpMarketplaceRepository] (checksum, archive +/// limits, sandbox policy); this class only adds where the list comes from and +/// that an archive may only come from a GitHub release. +class StaticRegistryRepository extends HttpMarketplaceRepository { + StaticRegistryRepository({ + this.indexUrl = defaultIndexUrl, + required this.hostVersion, + super.client, + super.allowLocalhostInDebug, + this.cacheTtl = const Duration(minutes: 10), + DateTime Function()? now, + }) : _http = client ?? http.Client(), + _now = now ?? DateTime.now, + super( + baseUrl: indexUrl, + extraTrustedDownloadHosts: _releaseHosts, + ); + + static const defaultIndexUrl = + 'https://queryahub.github.io/extensions/index.json'; + + /// The index format this client reads. A newer registry is refused rather + /// than half understood. + static const supportedSchema = 1; + + /// Where a release asset is served from: the redirect of + /// `github.com///releases/download/...` ends on one of these. + static const _releaseHosts = [ + 'github.com', + 'objects.githubusercontent.com', + 'release-assets.githubusercontent.com', + ]; + + /// Querya Desktop version, to hide extensions whose `engines.querya_desktop` + /// range does not include it. + final String hostVersion; + final String indexUrl; + final Duration cacheTtl; + final http.Client _http; + final DateTime Function() _now; + + List? _cache; + DateTime? _cachedAt; + + static final _releasePath = RegExp(r'^/[^/]+/[^/]+/releases/download/[^/]+/[^/]+$'); + + /// `https://github.com///releases/download//` only. + static bool isReleaseAssetUrl(Uri uri) => + uri.scheme == 'https' && + uri.host.toLowerCase() == 'github.com' && + _releasePath.hasMatch(uri.path); + + /// The installable, compatible extensions of the index (cached for + /// [cacheTtl]; a failed refresh keeps serving the last good copy). + Future> _entries() async { + final cached = _cache; + final at = _cachedAt; + if (cached != null && at != null && _now().difference(at) < cacheTtl) { + return cached; + } + try { + final response = await _http + .get(Uri.parse(indexUrl)) + .timeout(const Duration(seconds: 15)); + if (response.statusCode != 200) { + throw MarketplaceException( + 'Could not load the registry (HTTP ${response.statusCode})'); + } + final body = response.body; + final decoded = await Isolate.run(() => jsonDecode(body)); + _cache = parseIndex(decoded, hostVersion: hostVersion); + _cachedAt = _now(); + return _cache!; + } catch (e) { + if (cached != null) { + debugPrint('Registry refresh failed, using the last copy: $e'); + return cached; + } + if (e is MarketplaceException) rethrow; + throw MarketplaceException('Could not load the registry: $e'); + } + } + + /// Entries of a decoded `index.json` that can be shown: not yanked, with a + /// release-asset URL and a checksum, and compatible with [hostVersion]. + @visibleForTesting + static List parseIndex( + Object? decoded, { + required String hostVersion, + }) { + if (decoded is! Map) { + throw MarketplaceException('The registry index is not a JSON object'); + } + final schema = decoded['schema']; + if (schema != supportedSchema) { + throw MarketplaceException( + 'The registry uses index format $schema; this Querya reads ' + '$supportedSchema. Update Querya Desktop.'); + } + final host = HostVersion.parse(hostVersion); + final out = []; + for (final raw in (decoded['extensions'] as List? ?? const [])) { + if (raw is! Map) continue; + final json = Map.from(raw); + if (json['yanked'] == true) continue; + try { + final manifest = ExtensionManifest.fromJson(json); + final url = Uri.tryParse(manifest.downloadUrl ?? ''); + if (url == null || !isReleaseAssetUrl(url)) continue; + if ((manifest.sha256Checksum ?? '').trim().isEmpty) continue; + final range = manifest.engines['querya_desktop']; + if (range != null && engineRangeAllows(host, range) == false) continue; + out.add(manifest); + } catch (_) { + // One malformed entry must not hide the rest of the registry. + } + } + return out; + } + + @override + Future> getTrending({ExtensionType? type}) async { + final all = await _entries(); + return [ + for (final m in all) + if (type == null || m.type == type) m, + ]; + } + + @override + Future> search(String query, + {ExtensionType? type}) async { + final q = query.trim().toLowerCase(); + final all = await getTrending(type: type); + if (q.isEmpty) return all; + return [ + for (final m in all) + if (m.name.toLowerCase().contains(q) || + m.id.toLowerCase().contains(q) || + m.publisher.toLowerCase().contains(q) || + (m.description?.toLowerCase().contains(q) ?? false) || + m.tags.any((t) => t.toLowerCase().contains(q))) + m, + ]; + } + + @override + Future download(String url, {void Function(double)? onProgress}) { + final uri = Uri.tryParse(url); + if (uri == null || !isReleaseAssetUrl(uri)) { + throw MarketplaceException( + 'Only a GitHub release asset may be downloaded from the registry: $url'); + } + return super.download(url, onProgress: onProgress); + } +} diff --git a/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart new file mode 100644 index 00000000..76b5e26c --- /dev/null +++ b/test/core/market/static_registry_repository_test.dart @@ -0,0 +1,250 @@ +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:http/http.dart' as http; +import 'package:http/testing.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_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/market/static_registry_repository.dart'; + +const _sha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _asset = 'https://github.com/acme/ext/releases/download/v1.0.0/ext.zip'; + +Map _entry( + String id, { + String type = 'theme', + String url = _asset, + Object? sha = _sha, + String engines = '>=0.4.19', + bool yanked = false, + List tags = const [], +}) => + { + 'id': id, + 'name': 'Name of $id', + 'version': '1.0.0', + 'publisher': 'Acme', + 'type': type, + 'engines': {'querya_desktop': engines}, + 'downloadUrl': url, + 'sha256': sha, + 'tags': tags, + if (yanked) 'yanked': true, + }; + +String _index(List> entries, {Object schema = 1}) => + jsonEncode({'schema': schema, 'extensions': entries}); + +void main() { + group('StaticRegistryRepository.parseIndex', () { + List ids(Object decoded, {String host = '0.5.0'}) => [ + for (final m + in StaticRegistryRepository.parseIndex(decoded, hostVersion: host)) + m.id, + ]; + + test('keeps installable entries and drops the rest', () { + final decoded = { + 'schema': 1, + 'extensions': [ + _entry('ok.one'), + _entry('bad.yanked', yanked: true), + _entry('bad.host', url: 'https://example.com/ext.zip'), + _entry('bad.archive', url: 'https://github.com/acme/ext/archive/main.zip'), + _entry('bad.nosha', sha: ''), + _entry('bad.nullsha', sha: null), + _entry('bad.engines', engines: '>=9.0.0'), + {'name': 'no id at all'}, + 'not an object', + _entry('ok.two'), + ], + }; + expect(ids(decoded), ['ok.one', 'ok.two']); + }); + + test('hides extensions the running Querya is too old for', () { + final decoded = { + 'schema': 1, + 'extensions': [_entry('new.only', engines: '>=0.5.0'), _entry('any')], + }; + expect(ids(decoded, host: '0.4.19'), ['any']); + expect(ids(decoded, host: '0.5.0'), ['new.only', 'any']); + }); + + test('keeps an engines range it cannot read (the host does not enforce it)', () { + expect(ids({'schema': 1, 'extensions': [_entry('x', engines: '*')]}), ['x']); + }); + + test('refuses an index of another format or shape', () { + expect(() => StaticRegistryRepository.parseIndex({'schema': 2, 'extensions': []}, hostVersion: '0.5.0'), + throwsA(isA())); + expect(() => StaticRegistryRepository.parseIndex([], hostVersion: '0.5.0'), + throwsA(isA())); + }); + }); + + group('StaticRegistryRepository.isReleaseAssetUrl', () { + bool ok(String u) => StaticRegistryRepository.isReleaseAssetUrl(Uri.parse(u)); + + test('accepts only a GitHub release asset over https', () { + expect(ok(_asset), isTrue); + expect(ok('http://github.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://evil.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://github.com.evil.com/acme/ext/releases/download/v1/ext.zip'), isFalse); + expect(ok('https://github.com/acme/ext/archive/refs/heads/main.zip'), isFalse); + expect(ok('https://github.com/acme/ext/releases/latest'), isFalse); + expect(ok('https://github.com/acme/ext/releases/download/v1/'), isFalse); + expect(ok('https://github.com/acme/ext/releases/download/v1/a/b.zip'), isFalse); + }); + }); + + group('StaticRegistryRepository', () { + var requests = 0; + var now = DateTime(2026, 10, 16, 12); + + StaticRegistryRepository repo( + http.Client client, { + String host = '0.5.0', + }) => + StaticRegistryRepository( + hostVersion: host, + client: client, + now: () => now, + ); + + setUp(() { + requests = 0; + now = DateTime(2026, 10, 16, 12); + }); + + MockClient indexClient(List> entries) => + MockClient((request) async { + requests++; + expect(request.url.toString(), StaticRegistryRepository.defaultIndexUrl); + return http.Response(_index(entries), 200); + }); + + test('lists and filters by type', () async { + final r = repo(indexClient([ + _entry('a.theme'), + _entry('b.driver', type: 'databaseDriver'), + ])); + expect((await r.getTrending()).map((m) => m.id), ['a.theme', 'b.driver']); + expect((await r.getTrending(type: ExtensionType.theme)).map((m) => m.id), ['a.theme']); + }); + + test('searches name, id, publisher and tags', () async { + final r = repo(indexClient([ + _entry('a.one', tags: ['nord']), + _entry('b.two'), + ])); + expect((await r.search('NORD')).map((m) => m.id), ['a.one']); + expect((await r.search('b.two')).map((m) => m.id), ['b.two']); + expect((await r.search('acme')).length, 2); + expect(await r.search('nothing like it'), isEmpty); + expect((await r.search(' ')).length, 2); + }); + + test('caches the index for the TTL and refetches after it', () async { + final r = repo(indexClient([_entry('a')])); + await r.getTrending(); + await r.search('a'); + expect(requests, 1); + now = now.add(const Duration(minutes: 11)); + await r.getTrending(); + expect(requests, 2); + }); + + test('serves the last good copy when a refresh fails', () async { + var fail = false; + final r = repo(MockClient((request) async { + if (fail) return http.Response('down', 503); + return http.Response(_index([_entry('a')]), 200); + })); + expect(await r.getTrending(), hasLength(1)); + fail = true; + now = now.add(const Duration(hours: 1)); + expect(await r.getTrending(), hasLength(1)); + }); + + test('reports an unreachable registry when there is no copy yet', () async { + final r = repo(MockClient((request) async => http.Response('nope', 404))); + expect( + r.getTrending(), + throwsA(isA() + .having((e) => e.message, 'message', contains('HTTP 404'))), + ); + }); + + test('download refuses anything but a release asset', () { + final r = repo(indexClient([])); + expect(() => r.download('https://example.com/ext.zip'), + throwsA(isA())); + expect(() => r.download('https://github.com/acme/ext/archive/main.zip'), + throwsA(isA())); + }); + + group('install', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_static_registry_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + ExtensionPaths.mockExtensionsDirectory = null; + }); + + test('downloads the release asset, checks its hash and unpacks it', () async { + final archive = Archive() + ..addFile(ArchiveFile('theme.json', 2, utf8.encode('{}'))); + final zip = ZipEncoder().encode(archive); + final sha = sha256.convert(zip).toString(); + + final r = repo(MockClient((request) async { + if (request.url.toString() == StaticRegistryRepository.defaultIndexUrl) { + return http.Response(_index([_entry('acme.theme', sha: sha)]), 200); + } + expect(request.url.toString(), _asset); + return http.Response.bytes(zip, 200); + })); + + final entry = (await r.getTrending()).single; + await r.install(entry); + + expect(File(p.join(tempDir.path, 'acme.theme', 'theme.json')).existsSync(), isTrue); + }); + + test('refuses an archive whose hash is not the one in the index', () async { + final archive = Archive() + ..addFile(ArchiveFile('theme.json', 2, utf8.encode('{}'))); + final zip = ZipEncoder().encode(archive); + + final r = repo(MockClient((request) async { + if (request.url.toString() == StaticRegistryRepository.defaultIndexUrl) { + return http.Response(_index([_entry('acme.theme')]), 200); + } + return http.Response.bytes(zip, 200); + })); + + final entry = (await r.getTrending()).single; + expect( + () => r.install(entry), + throwsA(isA() + .having((e) => e.message, 'message', contains('SHA256 checksum mismatch'))), + ); + expect(Directory(p.join(tempDir.path, 'acme.theme')).existsSync() && + File(p.join(tempDir.path, 'acme.theme', 'theme.json')).existsSync(), isFalse); + }); + }); + }); +}