diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index a4ea644e..a2eff155 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/mongodb_uri.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; @@ -88,13 +89,8 @@ class MongoConnection { } final rawUri = _sessionUri ?? _connectionString; if (rawUri != null && rawUri.isNotEmpty) { - try { - final parsed = Uri.parse(rawUri); - final path = parsed.path.replaceFirst('/', '').trim(); - if (path.isNotEmpty) { - return path; - } - } catch (_) {} + final name = MongoUri.tryParse(rawUri)?.databaseName?.trim(); + if (name != null && name.isNotEmpty) return name; } return null; } @@ -119,10 +115,14 @@ class MongoConnection { final effectiveConnStr = connStr ?? _connectionString; if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { if (hostOverride != null) { - final parsed = Uri.parse(effectiveConnStr); - return parsed - .replace(host: hostOverride, port: portOverride) - .toString(); + // Through an SSH tunnel: the one local endpoint, whatever seeds the + // string lists (#1309). + final parsed = MongoUri.tryParse(effectiveConnStr); + if (parsed == null) { + throw FormatException( + 'Not a mongodb:// connection string', effectiveConnStr); + } + return parsed.forTunnel(hostOverride, portOverride ?? 27017).toString(); } return effectiveConnStr; } @@ -185,30 +185,28 @@ class MongoConnection { /// one the user was created in. String buildUriForDatabase(String databaseName, {String? pass, String? connStr}) { final baseUri = buildConnectionUri(pass: pass, connStr: connStr); - final uri = Uri.parse(baseUri); + final uri = MongoUri.tryParse(baseUri); + if (uri == null) { + throw FormatException('Not a mongodb:// connection string', baseUri); + } // Determine the authSource that should be used. // 1) Already present in the query → keep it. // 2) Not present but credentials exist → use the original path db, or // fall back to "admin" (Mongo's default authSource). - final existingAuthSource = uri.queryParameters['authSource']; - final hasCredentials = - uri.userInfo.isNotEmpty || (username != null && username!.isNotEmpty); - - Map? newQueryParams; - if (existingAuthSource == null && hasCredentials) { - // Original db from the URI path (strip leading '/') - final origDb = uri.path.replaceFirst('/', ''); - final source = (origDb.isNotEmpty) ? origDb : 'admin'; - newQueryParams = Map.from(uri.queryParameters) - ..['authSource'] = source; - } - - final newUri = uri.replace( - path: '/$databaseName', - queryParameters: newQueryParams ?? uri.queryParameters, - ); - return newUri.toString(); + final hasCredentials = uri.userInfo != null || + (username != null && username!.isNotEmpty); + final params = Map.of(uri.params); + if (!params.containsKey('authSource') && hasCredentials) { + params['authSource'] = uri.databaseName ?? 'admin'; + } + + return uri + .copyWith( + path: '/${Uri.encodeComponent(databaseName)}', + params: params, + ) + .toString(); } /// Connects to MongoDB server. @@ -247,10 +245,13 @@ class MongoConnection { int targetPort = port; if (effectiveConnectionString != null && effectiveConnectionString.trim().isNotEmpty) { - final uri = Uri.tryParse(effectiveConnectionString.trim()); - if (uri != null && uri.host.isNotEmpty) { - targetHost = uri.host; - targetPort = uri.hasPort ? uri.port : 27017; + // The first seed; an SRV string has none a tunnel could reach and + // is refused when the URI is built. + final uri = MongoUri.tryParse(effectiveConnectionString.trim()); + if (uri != null) { + final seed = uri.firstHost; + targetHost = seed.host; + targetPort = seed.port ?? 27017; } } _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( @@ -300,9 +301,16 @@ class MongoConnection { hostOverride: hostOverride, portOverride: portOverride, ); - final parsed = Uri.parse(base); - final paths = extractSslCertificatePaths(parsed); - final params = Map.from(parsed.queryParameters); + final parsed = MongoUri.tryParse(base); + if (parsed == null) { + throw FormatException('Not a mongodb:// connection string', base); + } + final paths = SslCertificatePaths( + rootCert: parsed.params[kSslRootCertParam], + clientCert: parsed.params[kSslCertParam], + clientKey: parsed.params[kSslKeyParam], + ); + final params = Map.from(parsed.params); params.remove(kSslRootCertParam); params.remove(kSslCertParam); params.remove(kSslKeyParam); @@ -327,9 +335,7 @@ class MongoConnection { params['ssl'] = 'true'; } - return parsed - .replace(queryParameters: params.isEmpty ? null : params) - .toString(); + return parsed.copyWith(params: params).toString(); } /// Disconnects from MongoDB server. @@ -405,9 +411,7 @@ class MongoConnection { } static String? _databaseNameFromUri(String uri) { - final path = Uri.tryParse(uri)?.path.replaceFirst(RegExp(r'^/'), '') ?? ''; - if (path.isEmpty) return null; - return path.split('/').first; + return MongoUri.tryParse(uri)?.databaseName; } /// Checks if connection is active. diff --git a/lib/core/database/mongodb_uri.dart b/lib/core/database/mongodb_uri.dart new file mode 100644 index 00000000..0c32c98c --- /dev/null +++ b/lib/core/database/mongodb_uri.dart @@ -0,0 +1,175 @@ +/// A MongoDB connection string, split without `Uri.parse`. +/// +/// `mongodb://h1:27017,h2:27017/db?replicaSet=rs0` is valid MongoDB but not a +/// URI Dart accepts: the host list reads as a port (`Uri.tryParse` is null, +/// `Uri.parse` throws), so replica-set strings could not be imported, opened +/// or tunnelled (#1309). The pieces here are kept as written: the user info +/// stays percent-encoded. +class MongoUri { + const MongoUri._({ + required this.scheme, + required this.userInfo, + required this.hosts, + required this.path, + required this.params, + }); + + /// `mongodb` or `mongodb+srv`, lower case. + final String scheme; + + /// `user:password` as written (percent-encoded), or null. + final String? userInfo; + + /// `host`, `host:port` or `[::1]:port`, in order. + final List hosts; + + /// `''` or `/database`. + final String path; + + /// Query parameters, decoded, in order. + final Map params; + + bool get isSrv => scheme == 'mongodb+srv'; + + /// The database of the path, if there is one. + String? get databaseName { + final name = path.replaceFirst(RegExp(r'^/'), '').split('/').first; + if (name.isEmpty) return null; + try { + return Uri.decodeComponent(name); + } catch (_) { + return name; + } + } + + /// Null when [raw] is not a `mongodb://` / `mongodb+srv://` string with at + /// least one host. + static MongoUri? tryParse(String raw) { + final m = RegExp(r'^(mongodb(?:\+srv)?)://(.*)$', + caseSensitive: false, dotAll: true) + .firstMatch(raw.trim()); + if (m == null) return null; + var rest = m.group(2)!; + final hash = rest.indexOf('#'); + if (hash >= 0) rest = rest.substring(0, hash); + + var end = rest.length; + for (final stop in ['/', '?']) { + final i = rest.indexOf(stop); + if (i >= 0 && i < end) end = i; + } + var authority = rest.substring(0, end); + final tail = rest.substring(end); + + String? userInfo; + final at = authority.lastIndexOf('@'); + if (at >= 0) { + userInfo = authority.substring(0, at); + authority = authority.substring(at + 1); + } + final hosts = [ + for (final h in authority.split(',')) + if (h.trim().isNotEmpty) h.trim(), + ]; + if (hosts.isEmpty) return null; + + var path = ''; + var query = ''; + final q = tail.indexOf('?'); + if (q >= 0) { + path = tail.substring(0, q); + query = tail.substring(q + 1); + } else { + path = tail; + } + + final params = {}; + for (final pair in query.split('&')) { + if (pair.isEmpty) continue; + final eq = pair.indexOf('='); + final key = eq < 0 ? pair : pair.substring(0, eq); + final value = eq < 0 ? '' : pair.substring(eq + 1); + params[_decode(key)] = _decode(value); + } + return MongoUri._( + scheme: m.group(1)!.toLowerCase(), + userInfo: userInfo != null && userInfo.isNotEmpty ? userInfo : null, + hosts: hosts, + path: path, + params: params, + ); + } + + static String _decode(String v) { + try { + return Uri.decodeQueryComponent(v); + } catch (_) { + return v; + } + } + + /// The first seed: host and port (null when none is written). + ({String host, int? port}) get firstHost { + final h = hosts.first; + if (h.startsWith('[')) { + final close = h.indexOf(']'); + if (close > 0) { + final port = h.length > close + 2 && h[close + 1] == ':' + ? int.tryParse(h.substring(close + 2)) + : null; + return (host: h.substring(1, close), port: port); + } + } + final colon = h.lastIndexOf(':'); + if (colon > 0 && !h.substring(0, colon).contains(':')) { + return (host: h.substring(0, colon), port: int.tryParse(h.substring(colon + 1))); + } + return (host: h, port: null); + } + + MongoUri copyWith({ + String? scheme, + List? hosts, + String? path, + Map? params, + }) => + MongoUri._( + scheme: scheme ?? this.scheme, + userInfo: userInfo, + hosts: hosts ?? this.hosts, + path: path ?? this.path, + params: params ?? this.params, + ); + + /// This string pointing at one local tunnel endpoint instead of its seeds. + /// + /// The tunnel reaches one server, so the driver must not go looking for the + /// replica set's other members: `directConnection=true` unless the string + /// says otherwise. An SRV string names a DNS record, not a host and port a + /// tunnel could reach, so it cannot be tunnelled. + MongoUri forTunnel(String host, int port) { + if (isSrv) { + throw UnsupportedError( + 'An SSH tunnel cannot be used with a mongodb+srv:// connection ' + 'string: it names a DNS record that resolves to hosts the tunnel ' + 'cannot know. Use a mongodb:// string with the server\'s host and ' + 'port.', + ); + } + final bracket = host.contains(':') && !host.startsWith('['); + return copyWith( + hosts: ['${bracket ? '[$host]' : host}:$port'], + params: {'directConnection': 'true', ...params}, + ); + } + + @override + String toString() { + final query = params.entries + .map((e) => '${Uri.encodeQueryComponent(e.key)}=' + '${Uri.encodeQueryComponent(e.value)}') + .join('&'); + return '$scheme://${userInfo == null ? '' : '$userInfo@'}' + '${hosts.join(',')}$path${query.isEmpty ? '' : '?$query'}'; + } +} diff --git a/lib/core/security/ssl_certificate_support.dart b/lib/core/security/ssl_certificate_support.dart index 15dc0229..ae6e3852 100644 --- a/lib/core/security/ssl_certificate_support.dart +++ b/lib/core/security/ssl_certificate_support.dart @@ -40,8 +40,24 @@ SslCertificatePaths extractSslCertificatePaths(Uri uri) { SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { if (raw == null || raw.trim().isEmpty) return const SslCertificatePaths(); final uri = Uri.tryParse(raw.trim()); - if (uri == null) return const SslCertificatePaths(); - return extractSslCertificatePaths(uri); + if (uri != null) return extractSslCertificatePaths(uri); + // Not a URI Dart accepts (a MongoDB host list, `h1:27017,h2:27017`): the + // certificate paths are still in its query. + final q = raw.indexOf('?'); + if (q < 0) return const SslCertificatePaths(); + var query = raw.substring(q + 1); + final hash = query.indexOf('#'); + if (hash >= 0) query = query.substring(0, hash); + try { + final params = Uri.splitQueryString(query); + return SslCertificatePaths( + rootCert: params[kSslRootCertParam], + clientCert: params[kSslCertParam], + clientKey: params[kSslKeyParam], + ); + } catch (_) { + return const SslCertificatePaths(); + } } Map sslCertificateQueryParams(SslCertificatePaths paths) { diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 8b43a12c..742992e0 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/database/mongodb_uri.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; const _supportedSchemes = { @@ -25,7 +26,18 @@ const _validPostgresSslModes = { return (row: null, error: 'URL/URI is required.'); } - final uri = Uri.tryParse(trimmed); + var uri = Uri.tryParse(trimmed); + if (uri == null) { + // A MongoDB host list (`mongodb://h1:27017,h2:27017/db?replicaSet=rs0`) + // is valid MongoDB but not a URI Dart parses: read the first seed, the + // row keeps the whole string (#1309). + final mongo = MongoUri.tryParse(trimmed); + if (mongo != null) { + uri = Uri.tryParse( + mongo.copyWith(hosts: [mongo.hosts.first]).toString(), + ); + } + } if (uri == null || uri.scheme.isEmpty) { return (row: null, error: 'Invalid URL/URI format.'); } diff --git a/test/core/database/mongodb_uri_test.dart b/test/core/database/mongodb_uri_test.dart new file mode 100644 index 00000000..8cc59f00 --- /dev/null +++ b/test/core/database/mongodb_uri_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_uri.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; + +/// #1309: replica-set connection strings. +void main() { + const replicaSet = + 'mongodb://app:s3cret@db1.example:27017,db2.example:27018/shop' + '?replicaSet=rs0&authSource=admin'; + + group('MongoUri', () { + test('reads a host list that Uri.parse rejects', () { + expect(Uri.tryParse(replicaSet), isNull); + + final u = MongoUri.tryParse(replicaSet)!; + expect(u.scheme, 'mongodb'); + expect(u.userInfo, 'app:s3cret'); + expect(u.hosts, ['db1.example:27017', 'db2.example:27018']); + expect(u.databaseName, 'shop'); + expect(u.params, {'replicaSet': 'rs0', 'authSource': 'admin'}); + expect(u.firstHost, (host: 'db1.example', port: 27017)); + expect(u.toString(), replicaSet); + }); + + test('a single host, no database, no port, an SRV scheme', () { + final single = MongoUri.tryParse('mongodb://localhost')!; + expect(single.hosts, ['localhost']); + expect(single.databaseName, isNull); + expect(single.firstHost, (host: 'localhost', port: null)); + + final srv = MongoUri.tryParse('MongoDB+SRV://u:p@cluster0.example/app')!; + expect(srv.isSrv, isTrue); + expect(srv.scheme, 'mongodb+srv'); + expect(srv.firstHost.host, 'cluster0.example'); + }); + + test('a percent-encoded @ in the password stays in the user info', () { + final u = MongoUri.tryParse('mongodb://u:p%40ss@h1:27017,h2:27017/db')!; + expect(u.userInfo, 'u:p%40ss'); + expect(u.hosts, ['h1:27017', 'h2:27017']); + }); + + test('an IPv6 seed and things that are not MongoDB', () { + expect(MongoUri.tryParse('mongodb://[::1]:27018/db')!.firstHost, + (host: '::1', port: 27018)); + expect(MongoUri.tryParse('postgres://h/db'), isNull); + expect(MongoUri.tryParse('mongodb://'), isNull); + expect(MongoUri.tryParse('not a uri'), isNull); + }); + + test('a tunnel is one local endpoint, direct, and not for SRV', () { + final t = MongoUri.tryParse(replicaSet)!.forTunnel('127.0.0.1', 50111); + expect(t.hosts, ['127.0.0.1:50111']); + expect(t.params['directConnection'], 'true'); + expect(t.params['replicaSet'], 'rs0'); + expect(t.userInfo, 'app:s3cret'); + expect(t.databaseName, 'shop'); + + // An explicit directConnection is the user's. + final kept = + MongoUri.tryParse('mongodb://h1,h2/db?directConnection=false')! + .forTunnel('127.0.0.1', 1); + expect(kept.params['directConnection'], 'false'); + + expect( + () => MongoUri.tryParse('mongodb+srv://u:p@c.example/db')! + .forTunnel('127.0.0.1', 1), + throwsUnsupportedError, + ); + }); + }); + + group('MongoConnection with a replica-set string', () { + MongoConnection connection({String? uri}) => MongoConnection( + id: 0, + name: 'rs', + host: 'db1.example', + connectionString: uri ?? replicaSet, + ); + + test('the database of the string is the effective one', () { + expect(connection().effectiveDatabase, 'shop'); + }); + + test('opening another database keeps the seeds and sets the path', () { + final u = connection().buildUriForDatabase('reports'); + final parsed = MongoUri.tryParse(u)!; + expect(parsed.hosts, ['db1.example:27017', 'db2.example:27018']); + expect(parsed.databaseName, 'reports'); + expect(parsed.params['replicaSet'], 'rs0'); + expect(parsed.params['authSource'], 'admin'); + }); + + test('authSource defaults to the database the user was made in', () { + final u = connection( + uri: 'mongodb://app:pw@h1:27017,h2:27017/shop?replicaSet=rs0', + ).buildUriForDatabase('reports'); + expect(MongoUri.tryParse(u)!.params['authSource'], 'shop'); + }); + + test('through a tunnel the string becomes the local endpoint', () { + final u = connection().buildConnectionUri( + hostOverride: '127.0.0.1', + portOverride: 50111, + ); + final parsed = MongoUri.tryParse(u)!; + expect(parsed.hosts, ['127.0.0.1:50111']); + expect(parsed.params['directConnection'], 'true'); + }); + + test('a tunnel with an SRV string is refused with an explanation', () { + final c = connection(uri: 'mongodb+srv://u:p@cluster0.example/db'); + expect( + () => c.buildConnectionUri(hostOverride: '127.0.0.1', portOverride: 1), + throwsA(isA().having( + (e) => e.message, 'message', contains('mongodb+srv'))), + ); + }); + }); + + test('certificate paths are found in a host-list string', () { + final paths = extractSslCertificatePathsFromString( + 'mongodb://h1:27017,h2:27017/db?sslrootcert=%2Fetc%2Fca.pem', + ); + expect(paths.rootCert, '/etc/ca.pem'); + }); +} diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index c9d69d72..78bd0047 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -9,6 +9,32 @@ void main() { expect(result.error, 'URL/URI is required.'); }); + test('imports a MongoDB replica-set string with several hosts (#1309)', + () { + const url = 'mongodb://app:s3cret@db1.example:27017,db2.example:27018' + '/shop?replicaSet=rs0&authSource=admin'; + final result = parseConnectionUrlInput(url); + + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mongodb'); + expect(row.host, 'db1.example'); + expect(row.port, 27017); + expect(row.username, 'app'); + expect(row.password, 's3cret'); + expect(row.databaseName, 'shop'); + expect(row.authSource, 'admin'); + // The whole string is kept, every seed in it. + expect(row.connectionString, url); + }); + + test('a multi-host string with SRV or TLS still reads its options', () { + final row = parseConnectionUrlInput( + 'mongodb://h1:27017,h2:27017/db?ssl=true&replicaSet=rs') + .row!; + expect(row.useSSL, isTrue); + }); + test('returns error for invalid format', () { final result = parseConnectionUrlInput('not a url'); expect(result.row, isNull);