From 89cdd8478e65ca5639a3006389c9b53e7d3bc217 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:51:53 +0300 Subject: [PATCH 1/2] fix(connections): URL import edge cases: raw special characters, Windows SQLite, IPv6 (#1315) - A password with @, /, # or ? written as is no longer ends in 'Invalid URL/URI format.': the user info up to the last @ is percent-encoded and the stored string is the valid one. When it still cannot be read, the message names percent-encoding. - sqlite:///C:/data/app.db is C:/data/app.db, without the URI's slash. - An IPv6 host is bracketed in the default name and in the Mongo URI the connection builds. --- lib/core/database/mongodb_connection.dart | 6 +- .../connections/connection_url_parser.dart | 60 +++++++++++++++++-- .../connection_url_parser_test.dart | 49 +++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 9442243d..df159a3b 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -147,7 +147,11 @@ class MongoConnection { } // Add host and port - buffer.write(hostOverride ?? host); + // An IPv6 literal is bracketed in a URI. + final writtenHost = hostOverride ?? host; + buffer.write(writtenHost.contains(':') && !writtenHost.startsWith('[') + ? '[$writtenHost]' + : writtenHost); final effectivePort = portOverride ?? port; if (effectivePort != 27017 || hostOverride != null) { buffer.write(':$effectivePort'); diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 8b43a12c..8dd24ba8 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -25,9 +25,28 @@ const _validPostgresSslModes = { return (row: null, error: 'URL/URI is required.'); } - final uri = Uri.tryParse(trimmed); + var uri = Uri.tryParse(trimmed); + var url = trimmed; + if (uri == null) { + // A password with `@`, `/`, `#`, `?` or `:` written as is, as cloud + // consoles show it (#1315): read the user info up to the last `@` and + // encode it. + final recovered = _percentEncodeUserInfo(trimmed); + final u = recovered == null ? null : Uri.tryParse(recovered); + if (u != null) { + uri = u; + url = recovered!; + } + } if (uri == null || uri.scheme.isEmpty) { - return (row: null, error: 'Invalid URL/URI format.'); + return ( + row: null, + error: trimmed.contains('@') + ? 'Invalid URL/URI format. If the password contains special ' + 'characters, percent-encode them (@ is %40, / is %2F, # is ' + '%23, ? is %3F, : is %3A).' + : 'Invalid URL/URI format.', + ); } final scheme = uri.scheme.toLowerCase(); @@ -44,13 +63,39 @@ const _validPostgresSslModes = { return (row: null, error: sslResult.error); } - final row = _buildConnectionRow(trimmed, uri, scheme, sslResult.useSSL); + final row = _buildConnectionRow(url, uri, scheme, sslResult.useSSL); if (row == null) { return (row: null, error: 'Failed to parse connection URL.'); } return (row: row, error: null); } +/// [input] with the user info (`user:password`, up to the last `@` before the +/// query) percent-encoded, or null when there is none. Only used for a string +/// `Uri` rejected, so an `@` in a path cannot be mistaken for one. +String? _percentEncodeUserInfo(String input) { + final m = RegExp(r'^([A-Za-z][A-Za-z0-9+.\-]*://)(.*)$', dotAll: true) + .firstMatch(input); + if (m == null) return null; + final rest = m.group(2)!; + final query = rest.indexOf('?'); + final beforeQuery = query < 0 ? rest : rest.substring(0, query); + final at = beforeQuery.lastIndexOf('@'); + if (at <= 0) return null; + final userInfo = rest.substring(0, at); + final colon = userInfo.indexOf(':'); + final user = colon < 0 ? userInfo : userInfo.substring(0, colon); + final encodedUser = _encodeOnce(user); + final encoded = colon < 0 + ? encodedUser + : '$encodedUser:${_encodeOnce(userInfo.substring(colon + 1))}'; + return '${m.group(1)}$encoded${rest.substring(at)}'; +} + +/// [v] percent-encoded, leaving what already is (`%40` stays `%40`). +String _encodeOnce(String v) => Uri.encodeComponent(v) + .replaceAllMapped(RegExp(r'%25([0-9A-Fa-f]{2})'), (m) => '%${m.group(1)}'); + ({bool? useSSL, String? error}) _resolveSslForScheme(String scheme, Uri uri) { final type = _schemeToType(scheme); if (type == null) return (useSSL: null, error: null); @@ -135,6 +180,9 @@ ConnectionRow? _buildConnectionRow( path = ':memory:'; } else if (url.startsWith('sqlite:///')) { path = uri.path; + // `sqlite:///C:/data/app.db` is the file `C:/data/app.db`: the slash + // before a drive letter is the URI's, not the path's. + if (RegExp(r'^/[A-Za-z]:[/\\]').hasMatch(path)) path = path.substring(1); } else if (url.startsWith('sqlite://')) { path = url.substring(9); } else if (url.startsWith('sqlite:')) { @@ -203,7 +251,11 @@ String _connectionName( : 'SQLite (${host!.split('/').last})'; } - final cleanHost = host ?? 'localhost'; + var cleanHost = host ?? 'localhost'; + // `PostgreSQL: [::1]:5432`, not `PostgreSQL: ::1:5432`. + if (cleanHost.contains(':') && !cleanHost.startsWith('[')) { + cleanHost = '[$cleanHost]'; + } final cleanPort = port ?? defaultPort; final cleanDb = databaseName ?? ''; final typeName = switch (type) { diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index c9d69d72..7b1292ac 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -9,6 +9,55 @@ void main() { expect(result.error, 'URL/URI is required.'); }); + test('a password with special characters written as is imports (#1315)', + () { + for (final (url, password, host, db) in [ + ('postgres://user:p@ss@db.example:5432/app', 'p@ss', 'db.example', 'app'), + ('postgres://user:p#ss@db.example/app', 'p#ss', 'db.example', 'app'), + ('mysql://root:pa/ss@localhost/shop', 'pa/ss', 'localhost', 'shop'), + ('redis://default:a?b@cache.example:6380', 'a?b', 'cache.example', null), + ]) { + final r = parseConnectionUrlInput(url); + expect(r.error, isNull, reason: url); + final row = r.row!; + expect(row.password, password, reason: url); + expect(row.host, host, reason: url); + expect(row.databaseName, db, reason: url); + // The stored string is the encoded, valid one. + expect(Uri.tryParse(row.connectionString!), isNotNull, reason: url); + } + }); + + test('an already encoded password is not encoded twice', () { + final row = + parseConnectionUrlInput('postgres://u:p%40ss@h:5432/db').row!; + expect(row.password, 'p@ss'); + expect(row.connectionString, 'postgres://u:p%40ss@h:5432/db'); + }); + + test('a URL that stays invalid names percent-encoding', () { + final r = parseConnectionUrlInput('not a url@x'); + expect(r.row, isNull); + expect(r.error, contains('percent-encode')); + expect(parseConnectionUrlInput('not a url').error, + 'Invalid URL/URI format.'); + }); + + test('a Windows SQLite file URL has no slash before the drive', () { + final row = parseConnectionUrlInput('sqlite:///C:/data/app.db').row!; + expect(row.host, 'C:/data/app.db'); + // A POSIX path keeps its slash. + expect(parseConnectionUrlInput('sqlite:///tmp/test.db').row!.host, + '/tmp/test.db'); + }); + + test('an IPv6 host is bracketed in the default name', () { + final row = + parseConnectionUrlInput('postgresql://u:pw@[::1]:5432').row!; + expect(row.host, '::1'); + expect(row.name, 'PostgreSQL: [::1]:5432'); + }); + test('returns error for invalid format', () { final result = parseConnectionUrlInput('not a url'); expect(result.row, isNull); From af8c40b24adbbaef026e81479581da35347998dd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:52:10 +0300 Subject: [PATCH 2/2] fix(connections): the user info of a raw URL ends at the last @ (#1315) --- lib/features/connections/connection_url_parser.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 8dd24ba8..435f3610 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -70,17 +70,16 @@ const _validPostgresSslModes = { return (row: row, error: null); } -/// [input] with the user info (`user:password`, up to the last `@` before the -/// query) percent-encoded, or null when there is none. Only used for a string -/// `Uri` rejected, so an `@` in a path cannot be mistaken for one. +/// [input] with the user info (`user:password`, up to the last `@`) +/// percent-encoded, or null when there is none. Only used for a string `Uri` +/// rejected, so an `@` in a path or a query cannot be mistaken for one. String? _percentEncodeUserInfo(String input) { final m = RegExp(r'^([A-Za-z][A-Za-z0-9+.\-]*://)(.*)$', dotAll: true) .firstMatch(input); if (m == null) return null; final rest = m.group(2)!; - final query = rest.indexOf('?'); - final beforeQuery = query < 0 ? rest : rest.substring(0, query); - final at = beforeQuery.lastIndexOf('@'); + // The last `@`: a `?` or `/` before it belongs to the password. + final at = rest.lastIndexOf('@'); if (at <= 0) return null; final userInfo = rest.substring(0, at); final colon = userInfo.indexOf(':');