Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/core/database/mongodb_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
57 changes: 54 additions & 3 deletions lib/features/connections/connection_url_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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:')) {
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions test/features/connections/connection_url_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading