From 1b53f9ae9dfcbbfe34d5e303ecf57d95fc993301 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 11:51:36 +0300 Subject: [PATCH 1/2] fix(connections): validate PostgreSQL sslmode and map useSSL correctly (#249, #253, #254, #257, #258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject unsupported sslmode values (e.g. prefer, invalid) for PostgreSQL. - Map disable → useSSL=false; require, verify-ca, verify-full → useSSL=true. - Only store an explicit URI port; fall back to driver defaults when omitted. - Keep host:port in display name when no database is present. --- .../connections/connection_url_parser.dart | 92 ++++++++++++++++--- .../connection_url_parser_test.dart | 50 +++++++++- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 1634cbd7..82ca3723 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -11,6 +11,13 @@ const _supportedSchemes = { 'rediss', }; +const _validPostgresSslModes = { + 'disable', + 'require', + 'verify-ca', + 'verify-full', +}; + /// Parses a database connection URL into a [ConnectionRow], or returns an error message. ({ConnectionRow? row, String? error}) parseConnectionUrlInput(String input) { final trimmed = input.trim(); @@ -32,14 +39,67 @@ const _supportedSchemes = { ); } - final row = _buildConnectionRow(trimmed, uri, scheme); + final sslResult = _resolveSslForScheme(scheme, uri); + if (sslResult.error != null) { + return (row: null, error: sslResult.error); + } + + final row = _buildConnectionRow(trimmed, uri, scheme, sslResult.useSSL); if (row == null) { return (row: null, error: 'Failed to parse connection URL.'); } return (row: row, error: null); } -ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { +({bool? useSSL, String? error}) _resolveSslForScheme(String scheme, Uri uri) { + final type = _schemeToType(scheme); + if (type == null) return (useSSL: null, error: null); + + var useSSL = scheme == 'rediss'; + + if (type == 'postgresql') { + final sslMode = uri.queryParameters['sslmode']?.toLowerCase() ?? + uri.queryParameters['ssl']?.toLowerCase(); + if (sslMode != null && sslMode.isNotEmpty) { + if (!_validPostgresSslModes.contains(sslMode)) { + return ( + useSSL: null, + error: + 'Unsupported sslmode "$sslMode" for PostgreSQL. ' + 'Supported: disable, require, verify-ca, verify-full.', + ); + } + useSSL = sslMode != 'disable'; + } + } else if (type != 'sqlite') { + final sslQuery = uri.queryParameters['sslmode'] ?? + uri.queryParameters['ssl']; + if (sslQuery != null) { + final lowerSsl = sslQuery.toLowerCase(); + if (lowerSsl == 'true' || lowerSsl == 'require') { + useSSL = true; + } + } + } + + return (useSSL: useSSL, error: null); +} + +String? _schemeToType(String scheme) { + if (scheme == 'postgresql' || scheme == 'postgres') return 'postgresql'; + if (scheme == 'mysql') return 'mysql'; + if (scheme == 'sqlite') return 'sqlite'; + if (scheme == 'mongodb' || scheme == 'mongodb+srv') return 'mongodb'; + if (scheme == 'redis' || scheme == 'rediss') return 'redis'; + return null; +} + +ConnectionRow? _buildConnectionRow( + String url, + Uri uri, + String scheme, + bool? resolvedUseSSL, +) { String type; int? defaultPort; @@ -68,7 +128,7 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { String? databaseName; String? authSource; String? connectionString; - var useSSL = scheme == 'rediss'; + var useSSL = resolvedUseSSL ?? (scheme == 'rediss'); if (type == 'sqlite') { String path; @@ -86,7 +146,7 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { host = path; } else { host = uri.host.isEmpty ? null : uri.host; - port = uri.hasPort ? uri.port : defaultPort; + port = uri.hasPort ? uri.port : null; if (uri.userInfo.isNotEmpty) { final parts = uri.userInfo.split(':'); @@ -106,26 +166,18 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { authSource = uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; - final sslQuery = uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; - if (sslQuery != null) { - final lowerSsl = sslQuery.toLowerCase(); - if (lowerSsl == 'true' || lowerSsl == 'require' || lowerSsl == 'prefer') { - useSSL = true; - } - } - if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { connectionString = url; } } - final name = _connectionName(type, host, databaseName); + final name = _connectionName(type, host, port, databaseName, defaultPort); return ConnectionRow( type: type, name: name, host: host, - port: port, + port: port ?? defaultPort, username: username, password: password, databaseName: databaseName, @@ -136,12 +188,19 @@ ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { ); } -String _connectionName(String type, String? host, String? databaseName) { +String _connectionName( + String type, + String? host, + int? port, + String? databaseName, + int? defaultPort, +) { if (type == 'sqlite') { return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; } final cleanHost = host ?? 'localhost'; + final cleanPort = port ?? defaultPort; final cleanDb = databaseName ?? ''; final typeName = switch (type) { 'postgresql' => 'PostgreSQL', @@ -152,5 +211,8 @@ String _connectionName(String type, String? host, String? databaseName) { if (cleanDb.isNotEmpty) { return '$typeName: $cleanDb'; } + if (cleanPort != null) { + return '$typeName: $cleanHost:$cleanPort'; + } return '$typeName: $cleanHost'; } diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index 1e0d384c..49c9905f 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -54,6 +54,47 @@ void main() { expect(result.row!.useSSL, true); }); + test('parses postgresql sslmode=verify-full as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-full', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=verify-ca as SSL enabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=verify-ca', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses postgresql sslmode=disable as SSL disabled', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=disable', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, false); + }); + + test('returns error for postgresql sslmode=prefer', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=prefer', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + expect(result.error, contains('prefer')); + }); + + test('returns error for invalid postgresql sslmode', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=invalid', + ); + expect(result.row, isNull); + expect(result.error, contains('Unsupported sslmode')); + }); + test('parses mysql URL', () { final result = parseConnectionUrlInput( 'mysql://root:p%40ss@127.0.0.1:3307/sakila', @@ -112,11 +153,18 @@ void main() { expect(result.error, isNull); final row = result.row!; expect(row.type, 'redis'); - expect(row.name, 'Redis: localhost'); + expect(row.name, 'Redis: localhost:6379'); expect(row.password, 'password'); expect(row.connectionString, isNull); }); + test('uses default driver port when URI omits port', () { + final result = parseConnectionUrlInput('postgresql://localhost/mydb'); + expect(result.error, isNull); + expect(result.row!.port, 5432); + expect(result.row!.name, 'PostgreSQL: mydb'); + }); + test('parses rediss URL with SSL enabled', () { final result = parseConnectionUrlInput('rediss://localhost'); expect(result.error, isNull); From 2308db3ed928d565e1c59135a884503649288e3a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 11:53:17 +0300 Subject: [PATCH 2/2] fix(connections): surface PostgreSQL connection errors instead of swallowing them (#250, #251, #252) - Return ({bool ok, String? error}) from PostgresConnection.testConnection so the form can show the real failure reason. - Display the actual exception message in the connection tree instead of the static 'Error' label. --- lib/core/database/postgres_connection.dart | 11 ++++++----- .../connections_panel_postgres_connection.dart | 15 +++++++++------ .../postgresql/postgresql_connection_form.dart | 8 ++++++-- test/core/database/postgres_connection_test.dart | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 752cc0f5..fe76ee13 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -174,16 +174,17 @@ class PostgresConnection { ); } - Future testConnection() async { + /// Tests connectivity and returns a result with an optional error message. + Future<({bool ok, String? error})> testConnection() async { try { await connect(); if (_conn != null) { await _conn!.execute('SELECT 1'); - return true; + return (ok: true, error: null); } - return false; - } catch (_) { - return false; + return (ok: false, error: 'Connection could not be established.'); + } catch (e) { + return (ok: false, error: e.toString()); } finally { await disconnect(); } diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 9bc2a359..5021b92a 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -242,12 +242,15 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { material.Padding( padding: const material.EdgeInsets.only( left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + child: material.Tooltip( + message: _error!, + child: material.Text( + _error!, + overflow: material.TextOverflow.ellipsis, + maxLines: 2, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), ), ), if (_databases.isNotEmpty) diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4f72544a..8cdd2303 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -127,8 +127,12 @@ class _PostgresConnectionFormContentState useSSL: _useSSL, connectionString: uri.isEmpty ? null : uri, ); - final ok = await conn.testConnection(); - if (mounted) _showTestResult(ok ? 'success' : 'failed'); + final result = await conn.testConnection(); + if (mounted) { + _showTestResult( + result.ok ? 'success' : (result.error ?? 'failed'), + ); + } } catch (e) { if (mounted) _showTestResult('error: $e'); } diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index bcb68fca..4d2ea716 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -455,4 +455,19 @@ void main() { expect(out, isNot(contains('database=olddb'))); }); }); + + group('PostgresConnection.testConnection', () { + test('returns ok=false and error message when connection fails', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: 'postgresql://localhost/db?sslmode=invalid', + ); + final result = await conn.testConnection(); + expect(result.ok, false); + expect(result.error, isNotNull); + expect(result.error, contains('sslmode')); + }); + }); }