diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index f2ba95d4..7ae476d7 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 742992e0..7f59c2fd 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -27,6 +27,7 @@ const _validPostgresSslModes = { } var uri = Uri.tryParse(trimmed); + var url = 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 @@ -38,8 +39,26 @@ const _validPostgresSslModes = { ); } } + 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(); @@ -56,13 +75,38 @@ 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 `@`) +/// 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)!; + // 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(':'); + 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); @@ -147,6 +191,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:')) { @@ -215,7 +262,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 78bd0047..db7f9ddf 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('imports a MongoDB replica-set string with several hosts (#1309)', () { const url = 'mongodb://app:s3cret@db1.example:27017,db2.example:27018'