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.');