Validate and normalize hosted url. (#3030)
* Validate and normalize hosted url.
We normalize the URL for a _hosted pub server_ to have no slash if the
server is just a bare domain like `https://example.com`, but if the URL
contains a path like `https://example.com/my-folder` then we always
normalize to `https://example.com/my-folder/`.
The reason for normalizing the URL is to improve consistency in
`pubspec.lock` and make it easier to implement authentication without
risks of being tricked by incorrect prefixes.
Additionally, be normalizing to no slash for empty paths, and paths
always ending in a slash when path is non-empty, we gain the benefit
that relative URLs can always be constructed correctly using
`hostedUrl.resolve('api/packages/$package')`.
This additionally forbids a few edge cases such as:
* querystring in the hosted URL (`https://server.com/?query`),
* fragment in the hosted URL (`https://server.com/#hash`),
* user-info in the hosted URL (`https://user:pwd@server.com`).
These may have worked with previous versions of the `pub` client, but
most likely the _querystring_ or _fragment_ would cause URLs to be garbled.
Any user-info would likely have been ignored, this was not tested, any
usage of these options is considered unlikely.
Previously, `dart pub publish` would ignore the path in the hosted URL
and always upload to `/api/packages/new`. This commit fixes this issue.
diff --git a/lib/src/command/global_activate.dart b/lib/src/command/global_activate.dart
index 5b44fab..2959e6e 100644
--- a/lib/src/command/global_activate.dart
+++ b/lib/src/command/global_activate.dart
@@ -10,6 +10,7 @@
import '../command.dart';
import '../package_name.dart';
+import '../source/hosted.dart';
import '../utils.dart';
/// Handles the `global activate` pub command.
@@ -78,7 +79,15 @@
}
var overwrite = argResults['overwrite'];
- var hostedUrl = argResults['hosted-url'];
+ Uri hostedUrl;
+ if (argResults.wasParsed('hosted-url')) {
+ try {
+ hostedUrl = validateAndNormalizeHostedUrl(argResults['hosted-url']);
+ } on FormatException catch (e) {
+ usageException('Invalid hosted-url: $e');
+ }
+ }
+
Iterable<String> args = argResults.rest;
dynamic readArg([String error]) {
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index eb57e0f..58d87d2 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -10,11 +10,13 @@
import '../ascii_tree.dart' as tree;
import '../command.dart';
+import '../exceptions.dart' show DataException;
import '../exit_codes.dart' as exit_codes;
import '../http.dart';
import '../io.dart';
import '../log.dart' as log;
import '../oauth2.dart' as oauth2;
+import '../source/hosted.dart' show validateAndNormalizeHostedUrl;
import '../utils.dart';
import '../validator.dart';
@@ -33,20 +35,36 @@
/// The URL of the server to which to upload the package.
Uri get server {
+ if (_server != null) {
+ return _server;
+ }
+
// An explicit argument takes precedence.
if (argResults.wasParsed('server')) {
- return Uri.parse(argResults['server']);
+ try {
+ return _server = validateAndNormalizeHostedUrl(argResults['server']);
+ } on FormatException catch (e) {
+ usageException('Invalid server: $e');
+ }
}
// Otherwise, use the one specified in the pubspec.
- if (entrypoint.root.pubspec.publishTo != null) {
- return Uri.parse(entrypoint.root.pubspec.publishTo);
+ final publishTo = entrypoint.root.pubspec.publishTo;
+ if (publishTo != null) {
+ try {
+ return _server = validateAndNormalizeHostedUrl(publishTo);
+ } on FormatException catch (e) {
+ throw DataException('Invalid publish_to: $e');
+ }
}
- // Otherwise, use the default.
- return Uri.parse(cache.sources.hosted.defaultUrl);
+ // Use the default server if nothing else is specified
+ return _server = cache.sources.hosted.defaultUrl;
}
+ /// Cache value for [server].
+ Uri _server;
+
/// Whether the publish is just a preview.
bool get dryRun => argResults['dry-run'];
@@ -75,15 +93,15 @@
try {
await oauth2.withClient(cache, (client) {
return log.progress('Uploading', () async {
- // TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
- // should report that error and exit.
- var newUri = server.resolve('/api/packages/versions/new');
+ var newUri = server.resolve('api/packages/versions/new');
var response = await client.get(newUri, headers: pubApiHeaders);
var parameters = parseJsonResponse(response);
var url = _expectField(parameters, 'url', response);
if (url is! String) invalidServerResponse(response);
cloudStorageUrl = Uri.parse(url);
+ // TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
+ // should report that error and exit.
var request = http.MultipartRequest('POST', cloudStorageUrl);
var fields = _expectField(parameters, 'fields', response);
@@ -181,8 +199,14 @@
final warnings = <String>[];
final errors = <String>[];
- await Validator.runAll(entrypoint, packageSize, server.toString(),
- hints: hints, warnings: warnings, errors: errors);
+ await Validator.runAll(
+ entrypoint,
+ packageSize,
+ server,
+ hints: hints,
+ warnings: warnings,
+ errors: errors,
+ );
if (errors.isNotEmpty) {
log.error('Sorry, your package is missing '
diff --git a/lib/src/global_packages.dart b/lib/src/global_packages.dart
index a63d6a8..a863d41 100644
--- a/lib/src/global_packages.dart
+++ b/lib/src/global_packages.dart
@@ -123,7 +123,7 @@
String name, VersionConstraint constraint, List<String> executables,
{Map<String, FeatureDependency> features,
bool overwriteBinStubs,
- String url}) async {
+ Uri url}) async {
await _installInCache(
cache.hosted.source
.refFor(name, url: url)
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index a3ee1e1..516edae 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -28,6 +28,60 @@
import '../utils.dart';
import 'cached.dart';
+/// Validates and normalizes a [hostedUrl] which is pointing to a pub server.
+///
+/// A [hostedUrl] is a URL pointing to a _hosted pub server_ as defined by the
+/// [repository-spec-v2][1]. The default value is `pub.dartlang.org`, and can be
+/// overwritten using `PUB_HOSTED_URL`. It can also specified for individual
+/// hosted-dependencies in `pubspec.yaml`, and for the root package using the
+/// `publish_to` key.
+///
+/// The [hostedUrl] is always normalized to a [Uri] with path that ends in slash
+/// unless the path is merely `/`, in which case we normalize to the bare domain
+/// this keeps the [hostedUrl] and maintains avoids unnecessary churn in
+/// `pubspec.lock` files which contain `https://pub.dartlang.org`.
+///
+/// Throws [FormatException] if there is anything wrong [hostedUrl].
+///
+/// [1]: ../../../doc/repository-spec-v2.md
+Uri validateAndNormalizeHostedUrl(String hostedUrl) {
+ Uri u;
+ try {
+ u = Uri.parse(hostedUrl);
+ } on FormatException catch (e) {
+ throw FormatException(
+ 'invalid url: ${e.message}',
+ e.source,
+ e.offset,
+ );
+ }
+ if (!u.hasScheme || (u.scheme != 'http' && u.scheme != 'https')) {
+ throw FormatException('url scheme must be https:// or http://', hostedUrl);
+ }
+ if (!u.hasAuthority || u.host == '') {
+ throw FormatException('url must have a hostname', hostedUrl);
+ }
+ if (u.userInfo != '') {
+ throw FormatException('user-info is not supported in url', hostedUrl);
+ }
+ if (u.hasQuery) {
+ throw FormatException('querystring is not supported in url', hostedUrl);
+ }
+ if (u.hasFragment) {
+ throw FormatException('fragment is not supported in url', hostedUrl);
+ }
+ u = u.normalizePath();
+ // If we have a path of only `/`
+ if (u.path == '/') {
+ u = u.replace(path: '');
+ }
+ // If there is a path, and it doesn't end in a slash we normalize to slash
+ if (u.path.isNotEmpty && !u.path.endsWith('/')) {
+ u = u.replace(path: u.path + '/');
+ }
+ return u;
+}
+
/// A package source that gets packages from a package hosting site that uses
/// the same API as pub.dartlang.org.
class HostedSource extends Source {
@@ -43,7 +97,7 @@
: BoundHostedSource(this, systemCache);
/// Gets the default URL for the package server for hosted dependencies.
- String get defaultUrl {
+ Uri get defaultUrl {
// Changing this to pub.dev raises the following concerns:
//
// 1. It would blow through users caches.
@@ -55,49 +109,45 @@
//
// Clearly, a bit of investigation is necessary before we update this to
// pub.dev, it might be attractive to do next time we change the server API.
- return _defaultUrl ??= _pubHostedUrlConfig() ?? 'https://pub.dartlang.org';
- }
-
- String _defaultUrl;
-
- String _pubHostedUrlConfig() {
- var url = io.Platform.environment['PUB_HOSTED_URL'];
- if (url == null) return null;
- if (url.endsWith('/')) {
- url = url.substring(0, url.length - 1);
- }
- var uri = Uri.parse(url);
- if (!uri.isScheme('http') && !uri.isScheme('https')) {
+ try {
+ return _defaultUrl ??= validateAndNormalizeHostedUrl(
+ io.Platform.environment['PUB_HOSTED_URL'] ?? 'https://pub.dartlang.org',
+ );
+ } on FormatException catch (e) {
throw ConfigException(
- '`PUB_HOSTED_URL` must have either the scheme "https://" or "http://". '
- '"$url" is invalid.');
+ 'Invalid `PUB_HOSTED_URL="${e.source}"`: ${e.message}');
}
- return url;
}
+ Uri _defaultUrl;
+
/// Returns a reference to a hosted package named [name].
///
/// If [url] is passed, it's the URL of the pub server from which the package
- /// should be downloaded. It can be a [Uri] or a [String].
- PackageRef refFor(String name, {url}) =>
+ /// should be downloaded. [url] most be normalized and validated using
+ /// [validateAndNormalizeHostedUrl].
+ PackageRef refFor(String name, {Uri url}) =>
PackageRef(name, this, _descriptionFor(name, url));
/// Returns an ID for a hosted package named [name] at [version].
///
/// If [url] is passed, it's the URL of the pub server from which the package
- /// should be downloaded. It can be a [Uri] or a [String].
- PackageId idFor(String name, Version version, {url}) =>
+ /// should be downloaded. [url] most be normalized and validated using
+ /// [validateAndNormalizeHostedUrl].
+ PackageId idFor(String name, Version version, {Uri url}) =>
PackageId(name, this, version, _descriptionFor(name, url));
/// Returns the description for a hosted package named [name] with the
/// given package server [url].
- dynamic _descriptionFor(String name, [url]) {
- if (url == null) return name;
-
- if (url is! String && url is! Uri) {
- throw ArgumentError.value(url, 'url', 'must be a Uri or a String.');
+ dynamic _descriptionFor(String name, [Uri url]) {
+ if (url == null) {
+ return name;
}
-
+ try {
+ url = validateAndNormalizeHostedUrl(url.toString());
+ } on FormatException catch (e) {
+ throw ArgumentError.value(url, 'url', 'url must be normalized: $e');
+ }
return {'name': name, 'url': url.toString()};
}
@@ -134,9 +184,9 @@
///
/// If the package parses correctly, this returns a (name, url) pair. If not,
/// this throws a descriptive FormatException.
- Pair<String, String> _parseDescription(description) {
+ Pair<String, Uri> _parseDescription(description) {
if (description is String) {
- return Pair<String, String>(description, defaultUrl);
+ return Pair<String, Uri>(description, defaultUrl);
}
if (description is! Map) {
@@ -152,7 +202,16 @@
throw FormatException("The 'name' key must have a string value.");
}
- return Pair<String, String>(name, description['url'] ?? defaultUrl);
+ var url = defaultUrl;
+ final u = description['url'];
+ if (u != null) {
+ if (u is! String) {
+ throw FormatException("The 'url' key must be a string value.");
+ }
+ url = validateAndNormalizeHostedUrl(u);
+ }
+
+ return Pair<String, Uri>(name, url);
}
}
@@ -210,8 +269,7 @@
Future<Map<PackageId, _VersionInfo>> _fetchVersionsNoPrefetching(
PackageRef ref) async {
- var url = _makeUrl(
- ref.description, (server, package) => '$server/api/packages/$package');
+ final url = _listVersionsUrl(ref.description);
log.io('Get versions from $url.');
String bodyText;
@@ -393,15 +451,12 @@
}
/// Parses [description] into its server and package name components, then
- /// converts that to a Uri given [pattern].
- ///
- /// Ensures the package name is properly URL encoded.
- Uri _makeUrl(
- description, String Function(String server, String package) pattern) {
- var parsed = source._parseDescription(description);
- var server = parsed.last;
- var package = Uri.encodeComponent(parsed.first);
- return Uri.parse(pattern(server, package));
+ /// converts that to a Uri for listing versions of the given package.
+ Uri _listVersionsUrl(description) {
+ final parsed = source._parseDescription(description);
+ final hostedUrl = parsed.last;
+ final package = Uri.encodeComponent(parsed.first);
+ return hostedUrl.resolve('api/packages/$package');
}
/// Retrieves the pubspec for a specific version of a package that is
@@ -409,8 +464,7 @@
@override
Future<Pubspec> describeUncached(PackageId id) async {
final versions = await _scheduler.schedule(id.toRef());
- final url = _makeUrl(
- id.description, (server, package) => '$server/api/packages/$package');
+ final url = _listVersionsUrl(id.description);
return versions[id]?.pubspec ??
(throw PackageNotFoundException('Could not find package $id at $url'));
}
@@ -445,63 +499,71 @@
Future<Iterable<RepairResult>> repairCachedPackages() async {
if (!dirExists(systemCacheRoot)) return [];
- return (await Future.wait(listDir(systemCacheRoot).map(
- (serverDir) async {
- var url = _directoryToUrl(p.basename(serverDir));
- final results = <RepairResult>[];
- var packages = <Package>[];
- for (var entry in listDir(serverDir)) {
- try {
- packages.add(Package.load(null, entry, systemCache.sources));
- } catch (error, stackTrace) {
- log.error('Failed to load package', error, stackTrace);
- results.add(
- RepairResult(
- _idForBasename(
- p.basename(entry),
- url: _directoryToUrl(serverDir),
- ),
- success: false,
+ return (await Future.wait(listDir(systemCacheRoot).map((serverDir) async {
+ final directory = p.basename(serverDir);
+ Uri url;
+ try {
+ url = _directoryToUrl(directory);
+ } on FormatException {
+ log.error('Unable to detect hosted url from directory: $directory');
+ // If _directoryToUrl can't intepret a directory name, we just silently
+ // ignore it and hope it's because it comes from a newer version of pub.
+ //
+ // This is most likely because someone manually modified PUB_CACHE.
+ return <RepairResult>[];
+ }
+
+ final results = <RepairResult>[];
+ var packages = <Package>[];
+ for (var entry in listDir(serverDir)) {
+ try {
+ packages.add(Package.load(null, entry, systemCache.sources));
+ } catch (error, stackTrace) {
+ log.error('Failed to load package', error, stackTrace);
+ results.add(
+ RepairResult(
+ _idForBasename(
+ p.basename(entry),
+ url: url,
),
- );
- tryDeleteEntry(entry);
- }
- }
-
- // Delete the cached package listings.
- tryDeleteEntry(p.join(serverDir, _versionListingDirectory));
-
- packages.sort(Package.orderByNameAndVersion);
-
- return results
- ..addAll(await Future.wait(
- packages.map(
- (package) async {
- var id = source.idFor(package.name, package.version, url: url);
- try {
- await _download(id, package.dir);
- return RepairResult(id, success: true);
- } catch (error, stackTrace) {
- var message = 'Failed to repair ${log.bold(package.name)} '
- '${package.version}';
- if (url != source.defaultUrl) message += ' from $url';
- log.error('$message. Error:\n$error');
- log.fine(stackTrace);
-
- tryDeleteEntry(package.dir);
- return RepairResult(id, success: false);
- }
- },
+ success: false,
),
- ));
- },
- )))
+ );
+ tryDeleteEntry(entry);
+ }
+ }
+
+ // Delete the cached package listings.
+ tryDeleteEntry(p.join(serverDir, _versionListingDirectory));
+
+ packages.sort(Package.orderByNameAndVersion);
+
+ return results
+ ..addAll(await Future.wait(
+ packages.map((package) async {
+ var id = source.idFor(package.name, package.version, url: url);
+ try {
+ await _download(id, package.dir);
+ return RepairResult(id, success: true);
+ } catch (error, stackTrace) {
+ var message = 'Failed to repair ${log.bold(package.name)} '
+ '${package.version}';
+ if (url != source.defaultUrl) message += ' from $url';
+ log.error('$message. Error:\n$error');
+ log.fine(stackTrace);
+
+ tryDeleteEntry(package.dir);
+ return RepairResult(id, success: false);
+ }
+ }),
+ ));
+ })))
.expand((x) => x);
}
/// Returns the best-guess package ID for [basename], which should be a
/// subdirectory in a hosted cache.
- PackageId _idForBasename(String basename, {String url}) {
+ PackageId _idForBasename(String basename, {Uri url}) {
var components = split1(basename, '-');
var version = Version.none;
if (components.length > 1) {
@@ -599,7 +661,11 @@
///
/// Always throws an error, either the original one or a better one.
void _throwFriendlyError(
- error, StackTrace stackTrace, String package, String url) {
+ error,
+ StackTrace stackTrace,
+ String package,
+ Uri url,
+ ) {
if (error is PubHttpException) {
if (error.response.statusCode == 404) {
throw PackageNotFoundException(
@@ -643,7 +709,8 @@
/// any collisions, so the encoding is reversible.
///
/// This behavior is a bug, but is being preserved for compatibility.
- String _urlToDirectory(String url) {
+ String _urlToDirectory(Uri hostedUrl) {
+ var url = hostedUrl.toString();
// Normalize all loopback URLs to "localhost".
url = url.replaceAllMapped(
RegExp(r'^(https?://)(127\.0\.0\.1|\[::1\]|localhost)?'), (match) {
@@ -656,7 +723,10 @@
return '$scheme$localhost';
});
return replace(
- url, RegExp(r'[<>:"\\/|?*%]'), (match) => '%${match[0].codeUnitAt(0)}');
+ url,
+ RegExp(r'[<>:"\\/|?*%]'),
+ (match) => '%${match[0].codeUnitAt(0)}',
+ );
}
/// Given a directory name in the system cache, returns the URL of the server
@@ -666,26 +736,27 @@
/// directory name does not preserve the scheme, this has to guess at it. It
/// chooses "http" for loopback URLs (mainly to support the pub tests) and
/// "https" for all others.
- String _directoryToUrl(String url) {
+ Uri _directoryToUrl(String directory) {
// Decode the pseudo-URL-encoded characters.
var chars = '<>:"\\/|?*%';
for (var i = 0; i < chars.length; i++) {
var c = chars.substring(i, i + 1);
- url = url.replaceAll('%${c.codeUnitAt(0)}', c);
+ directory = directory.replaceAll('%${c.codeUnitAt(0)}', c);
}
// If the URL has an explicit scheme, use that.
- if (url.contains('://')) return url;
+ if (directory.contains('://')) {
+ return Uri.parse(directory);
+ }
// Otherwise, default to http for localhost and https for everything else.
var scheme =
- isLoopback(url.replaceAll(RegExp(':.*'), '')) ? 'http' : 'https';
- return '$scheme://$url';
+ isLoopback(directory.replaceAll(RegExp(':.*'), '')) ? 'http' : 'https';
+ return Uri.parse('$scheme://$directory');
}
/// Returns the server URL for [description].
- Uri _serverFor(description) =>
- Uri.parse(source._parseDescription(description).last);
+ Uri _serverFor(description) => source._parseDescription(description).last;
/// Enables speculative prefetching of dependencies of packages queried with
/// [getVersions].
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index a7a948a..7d58c5c 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -122,7 +122,7 @@
/// package, in bytes. This is used to validate that it's not too big to
/// upload to the server.
static Future<void> runAll(
- Entrypoint entrypoint, Future<int> packageSize, String serverUrl,
+ Entrypoint entrypoint, Future<int> packageSize, Uri serverUrl,
{List<String> hints, List<String> warnings, List<String> errors}) {
var validators = [
GitignoreValidator(entrypoint),
diff --git a/lib/src/validator/relative_version_numbering.dart b/lib/src/validator/relative_version_numbering.dart
index febd2ff..6609074 100644
--- a/lib/src/validator/relative_version_numbering.dart
+++ b/lib/src/validator/relative_version_numbering.dart
@@ -18,7 +18,7 @@
static const String semverUrl =
'https://dart.dev/tools/pub/versioning#semantic-versions';
- final String _server;
+ final Uri _server;
RelativeVersionNumberingValidator(Entrypoint entrypoint, this._server)
: super(entrypoint);
diff --git a/test/get/hosted/explain_bad_hosted_url_test.dart b/test/get/hosted/explain_bad_hosted_url_test.dart
index 4e547a6..6de6860 100644
--- a/test/get/hosted/explain_bad_hosted_url_test.dart
+++ b/test/get/hosted/explain_bad_hosted_url_test.dart
@@ -16,15 +16,19 @@
// Get once so it gets cached.
await pubGet(
environment: {'PUB_HOSTED_URL': 'abc://bad_scheme.com'},
- error: contains(
- 'PUB_HOSTED_URL` must have either the scheme "https://" or "http://".'),
+ error: allOf(
+ contains('PUB_HOSTED_URL'),
+ contains('url scheme must be https:// or http://'),
+ ),
exitCode: 78,
);
await pubGet(
environment: {'PUB_HOSTED_URL': ''},
- error: contains(
- 'PUB_HOSTED_URL` must have either the scheme "https://" or "http://".'),
+ error: allOf(
+ contains('PUB_HOSTED_URL'),
+ contains('url scheme must be https:// or http://'),
+ ),
exitCode: 78,
);
});
diff --git a/test/hosted/fail_gracefully_on_invalid_url_test.dart b/test/hosted/fail_gracefully_on_invalid_url_test.dart
new file mode 100644
index 0000000..9cbfbe8
--- /dev/null
+++ b/test/hosted/fail_gracefully_on_invalid_url_test.dart
@@ -0,0 +1,109 @@
+// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// @dart=2.10
+
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+void main() {
+ forBothPubGetAndUpgrade((command) {
+ test('fails gracefully if the url is invalid', () async {
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': 'not@url-com'}
+ }
+ })
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains('url scheme must be https:// or http://'),
+ exitCode: exit_codes.DATA,
+ environment: {
+ 'PUB_MAX_HTTP_RETRIES': '2',
+ },
+ );
+ });
+ test('fails gracefully if the url has querystring', () async {
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': 'http://example.foo/?key=value'}
+ }
+ })
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains('querystring'),
+ exitCode: exit_codes.DATA,
+ environment: {
+ 'PUB_MAX_HTTP_RETRIES': '2',
+ },
+ );
+ });
+
+ test('fails gracefully if the url has fragment', () async {
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': 'http://example.foo/#hash'}
+ }
+ })
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains('fragment'),
+ exitCode: exit_codes.DATA,
+ environment: {
+ 'PUB_MAX_HTTP_RETRIES': '2',
+ },
+ );
+ });
+
+ test('fails gracefully if the url has user-info (1)', () async {
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': 'http://user:pwd@example.foo/'}
+ }
+ })
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains('user-info'),
+ exitCode: exit_codes.DATA,
+ environment: {
+ 'PUB_MAX_HTTP_RETRIES': '2',
+ },
+ );
+ });
+
+ test('fails gracefully if the url has user-info (2)', () async {
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': 'http://user@example.foo/'}
+ }
+ })
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains('user-info'),
+ exitCode: exit_codes.DATA,
+ environment: {
+ 'PUB_MAX_HTTP_RETRIES': '2',
+ },
+ );
+ });
+ });
+}
diff --git a/test/hosted/will_normalize_hosted_url_test.dart b/test/hosted/will_normalize_hosted_url_test.dart
new file mode 100644
index 0000000..f2da266
--- /dev/null
+++ b/test/hosted/will_normalize_hosted_url_test.dart
@@ -0,0 +1,145 @@
+// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// @dart=2.10
+
+import 'package:http/http.dart' as http;
+import 'package:pub/src/exit_codes.dart' as exit_codes;
+import 'package:shelf/shelf.dart';
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+void main() {
+ forBothPubGetAndUpgrade((command) {
+ test('does not require slash on bare domain', () async {
+ await servePackages((b) => b..serve('foo', '1.2.3'));
+ // All the tests in this file assumes that [globalPackageServer.url]
+ // will be on the form:
+ // http://localhost:<port>
+ // In particular, that it doesn't contain anything path segment.
+ expect(Uri.parse(globalPackageServer.url).path, isEmpty);
+
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': globalPackageServer.url},
+ },
+ }),
+ ]).create();
+
+ await pubCommand(
+ command,
+ silent: contains('${globalPackageServer.url}/api/packages/foo'),
+ );
+ });
+
+ test('normalizes extra slash', () async {
+ await servePackages((b) => b..serve('foo', '1.2.3'));
+
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': globalPackageServer.url + '/'},
+ },
+ }),
+ ]).create();
+
+ await pubCommand(
+ command,
+ silent: contains('${globalPackageServer.url}/api/packages/foo'),
+ );
+ });
+
+ test('cannot normalize double slash', () async {
+ await servePackages((b) => b..serve('foo', '1.2.3'));
+ globalPackageServer.expect(
+ 'GET',
+ '//api/packages/foo',
+ (request) => Response.notFound(''),
+ );
+
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': globalPackageServer.url + '//'},
+ },
+ }),
+ ]).create();
+
+ await pubCommand(
+ command,
+ error: contains(
+ 'could not find package foo at ${globalPackageServer.url}//'),
+ exitCode: exit_codes.UNAVAILABLE,
+ );
+ });
+
+ /// Proxy request for '/my-folder/...' -> '/...'
+ ///
+ /// This is a bit of a hack, to easily test if hosted pub URLs with a path
+ /// segment works and if the slashes are normalized.
+ void _proxyMyFolderToRoot() {
+ globalPackageServer.extraHandlers[RegExp('/my-folder/.*')] = (r) async {
+ if (r.method != 'GET' && r.method != 'HEAD') {
+ return Response.forbidden(null);
+ }
+ final path = r.requestedUri.path.substring('/my-folder/'.length);
+ final res = await http.get(
+ Uri.parse(globalPackageServer.url + '/$path'),
+ );
+ return Response(res.statusCode, body: res.bodyBytes, headers: {
+ 'Content-Type': res.headers['Content-Type'],
+ });
+ };
+ }
+
+ test('will use normalized url with path', () async {
+ await servePackages((b) => b..serve('foo', '1.2.3'));
+ _proxyMyFolderToRoot();
+
+ // testing with a normalized URL
+ final testUrl = globalPackageServer.url + '/my-folder/';
+ final normalizedUrl = globalPackageServer.url + '/my-folder/';
+
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': testUrl},
+ },
+ }),
+ ]).create();
+
+ await pubCommand(command);
+
+ await d.dir(appPath, [
+ d.file('pubspec.lock', contains('"$normalizedUrl"')),
+ ]).validate();
+ });
+
+ test('will normalize url with path by adding slash', () async {
+ await servePackages((b) => b..serve('foo', '1.2.3'));
+ _proxyMyFolderToRoot();
+
+ // Testing with a URL that is missing the slash.
+ final testUrl = globalPackageServer.url + '/my-folder';
+ final normalizedUrl = globalPackageServer.url + '/my-folder/';
+
+ await d.dir(appPath, [
+ d.appPubspec({
+ 'foo': {
+ 'hosted': {'name': 'foo', 'url': testUrl},
+ },
+ }),
+ ]).create();
+
+ await pubCommand(command);
+
+ await d.dir(appPath, [
+ d.file('pubspec.lock', contains('"$normalizedUrl"')),
+ ]).validate();
+ });
+ });
+}
diff --git a/test/validator/relative_version_numbering_test.dart b/test/validator/relative_version_numbering_test.dart
index e7bf261..0a9a1f7 100644
--- a/test/validator/relative_version_numbering_test.dart
+++ b/test/validator/relative_version_numbering_test.dart
@@ -13,8 +13,10 @@
import '../test_pub.dart';
import 'utils.dart';
-Validator validator(Entrypoint entrypoint) =>
- RelativeVersionNumberingValidator(entrypoint, globalPackageServer.url);
+Validator validator(Entrypoint entrypoint) => RelativeVersionNumberingValidator(
+ entrypoint,
+ Uri.parse(globalPackageServer.url),
+ );
Future<void> setup({String sdkConstraint}) async {
await d.validPackage.create();
diff --git a/test/version_solver_test.dart b/test/version_solver_test.dart
index 8ae5a6e..44d0399 100644
--- a/test/version_solver_test.dart
+++ b/test/version_solver_test.dart
@@ -2968,7 +2968,7 @@
// If the dep uses the default hosted source, grab it from the test
// package server rather than pub.dartlang.org.
dep = registry.hosted
- .refFor(dep.name, url: globalPackageServer.url)
+ .refFor(dep.name, url: Uri.parse(globalPackageServer.url))
.withConstraint(dep.constraint);
}
expect(dep.allows(id), isTrue, reason: 'Expected $id to match $dep.');