Don't impose a strict size limit to uploads (#4014)

diff --git a/lib/src/authentication/client.dart b/lib/src/authentication/client.dart
index c36e529..ffa7889 100644
--- a/lib/src/authentication/client.dart
+++ b/lib/src/authentication/client.dart
@@ -11,6 +11,7 @@
 import '../http.dart';
 import '../log.dart' as log;
 import '../system_cache.dart';
+import '../utils.dart';
 import 'credential.dart';
 
 /// This client authenticates requests by injecting `Authentication` header to
@@ -83,11 +84,7 @@
       }
     }
     if (serverMessage != null) {
-      // Only allow printable ASCII, map anything else to whitespace, take
-      // at-most 1024 characters.
-      serverMessage = String.fromCharCodes(
-        serverMessage.runes.map((r) => 32 <= r && r <= 127 ? r : 32).take(1024),
-      );
+      serverMessage = sanitizeForTerminal(serverMessage);
     }
     throw AuthenticationException(response.statusCode, serverMessage);
   }
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index 9c4372c..8b63718 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -179,9 +179,7 @@
     } on PubHttpResponseException catch (error) {
       var url = error.response.request!.url;
       if (url == cloudStorageUrl) {
-        // TODO(nweiz): the response may have XML-formatted information about
-        // the error. Try to parse that out once we have an easily-accessible
-        // XML parser.
+        handleGCSError(error.response);
         fail(log.red('Failed to upload the package.'));
       } else if (Uri.parse(url.origin) == Uri.parse(host.origin)) {
         handleJsonError(error.response);
@@ -277,14 +275,17 @@
       '${tree.fromFiles(files, baseDir: entrypoint.rootDir, showFileSizes: true)}',
     );
 
-    var packageBytesFuture =
-        createTarGz(files, baseDir: entrypoint.rootDir).toBytes();
+    var packageBytes =
+        await createTarGz(files, baseDir: entrypoint.rootDir).toBytes();
+    log.message(
+      '\nTotal compressed archive size: ${_readableFileSize(packageBytes.length)}.\n',
+    );
 
     // Validate the package.
     var isValid = skipValidation
         ? true
         : await _validate(
-            packageBytesFuture.then((bytes) => bytes.length),
+            packageBytes.length,
             files,
           );
     if (!isValid) {
@@ -294,7 +295,7 @@
       log.message('The server may enforce additional checks.');
       return;
     } else {
-      await _publish(await packageBytesFuture);
+      await _publish(packageBytes);
     }
   }
 
@@ -307,7 +308,7 @@
 
   /// Validates the package. Completes to false if the upload should not
   /// proceed.
-  Future<bool> _validate(Future<int> packageSize, List<String> files) async {
+  Future<bool> _validate(int packageSize, List<String> files) async {
     final hints = <String>[];
     final warnings = <String>[];
     final errors = <String>[];
@@ -368,3 +369,15 @@
     return true;
   }
 }
+
+String _readableFileSize(int size) {
+  if (size >= 1 << 30) {
+    return '${size ~/ (1 << 30)} GB';
+  } else if (size >= 1 << 20) {
+    return '${size ~/ (1 << 20)} MB';
+  } else if (size >= 1 << 10) {
+    return '${size ~/ (1 << 10)} KB';
+  } else {
+    return '<1 KB';
+  }
+}
diff --git a/lib/src/http.dart b/lib/src/http.dart
index 294d567..d05c512 100644
--- a/lib/src/http.dart
+++ b/lib/src/http.dart
@@ -211,6 +211,41 @@
   fail(log.red(error['message'] as String));
 }
 
+/// Handles an unsuccessful XML-formatted response from google cloud storage.
+///
+/// Assumes messages are of the form in
+/// https://cloud.google.com/storage/docs/xml-api/reference-status
+///
+/// This is a poor person's XML parsing with regexps, but this should be
+/// sufficient for the specified messages.
+void handleGCSError(http.BaseResponse response) {
+  if (response is http.Response) {
+    final responseBody = response.body;
+    if (responseBody.contains('<?xml')) {
+      String? getTagText(String tag) {
+        final result = RegExp('<$tag>(.*)</$tag>').firstMatch(responseBody)?[1];
+        if (result == null) return null;
+        return sanitizeForTerminal(result);
+      }
+
+      final code = getTagText('Code');
+      // TODO(sigurdm): we could hard-code nice error messages for known codes.
+      final message = getTagText('Message');
+      // `Details` are not specified in the doc above, but have been observed in actual responses.
+      final details = getTagText('Details');
+      if (code != null) {
+        log.error('Server error code: $code');
+      }
+      if (message != null) {
+        log.error('Server message: $message');
+      }
+      if (details != null) {
+        log.error('Server details: $details');
+      }
+    }
+  }
+}
+
 /// Parses a response body, assuming it's JSON-formatted.
 ///
 /// Throws a user-friendly error if the response body is invalid JSON, or if
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 31ca1d1..d43780a 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -767,3 +767,13 @@
   String option(String name) => this[name] as String;
   String? optionWithoutDefault(String name) => this[name] as String?;
 }
+
+/// Limits the range of characters and length.
+///
+/// Useful for displaying externally provided strings.
+///
+/// Only allowd printable ASCII, map anything else to whitespace, take at-most
+/// 1024 characters.
+String sanitizeForTerminal(String input) => String.fromCharCodes(
+      input.runes.map((r) => 32 <= r && r <= 127 ? r : 32).take(1024),
+    );
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index 07ef9b3..ba923df 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -128,7 +128,7 @@
   /// upload to the server.
   static Future<void> runAll(
     Entrypoint entrypoint,
-    Future<int> packageSize,
+    int packageSize,
     Uri serverUrl,
     List<String> files, {
     required List<String> hints,
@@ -163,7 +163,7 @@
 
     final context = ValidationContext(
       entrypoint,
-      await packageSize,
+      packageSize,
       serverUrl,
       files,
     );
diff --git a/lib/src/validator/size.dart b/lib/src/validator/size.dart
index ae32496..a8a7eae 100644
--- a/lib/src/validator/size.dart
+++ b/lib/src/validator/size.dart
@@ -7,7 +7,7 @@
 import '../io.dart';
 import '../validator.dart';
 
-/// The maximum size of the package to upload (100 MB).
+/// The preferred maximum size of the package to upload (100 MB).
 const _maxSize = 100 * 1024 * 1024;
 
 /// A validator that validates that a package isn't too big.
@@ -19,17 +19,19 @@
     // Current implementation of Package.listFiles skips hidden files
     var ignoreExists = fileExists(entrypoint.root.path('.gitignore'));
 
-    var error = StringBuffer('Your package is $sizeInMb MB. Hosted '
-        'packages must be smaller than 100 MB.');
+    var hint = StringBuffer('''
+Your package is $sizeInMb MB.
+
+Consider the impact large downloads can have on the package consumer.''');
 
     if (ignoreExists && !entrypoint.root.inGitRepo) {
-      error.write(' Your .gitignore has no effect since your project '
+      hint.write('\nYour .gitignore has no effect since your project '
           'does not appear to be in version control.');
     } else if (!ignoreExists && entrypoint.root.inGitRepo) {
-      error.write(' Consider adding a .gitignore to avoid including '
+      hint.write('\nConsider adding a .gitignore to avoid including '
           'temporary files.');
     }
 
-    errors.add(error.toString());
+    hints.add(hint.toString());
   }
 }
diff --git a/test/lish/cloud_storage_upload_provides_an_error_test.dart b/test/lish/cloud_storage_upload_provides_an_error_test.dart
index bd14d84..a11ba95 100644
--- a/test/lish/cloud_storage_upload_provides_an_error_test.dart
+++ b/test/lish/cloud_storage_upload_provides_an_error_test.dart
@@ -22,14 +22,30 @@
     globalServer.expect('POST', '/upload', (request) {
       return request.read().drain().then((_) {
         return shelf.Response.notFound(
-          '<Error><Message>Your request sucked.</Message></Error>',
+          // Actual example of an error code we get from GCS
+          "<?xml version='1.0' encoding='UTF-8'?><Error><Code>EntityTooLarge</Code><Message>Your proposed upload is larger than the maximum object size specified in your Policy Document.</Message><Details>Content-length exceeds upper bound on range</Details></Error>",
           headers: {'content-type': 'application/xml'},
         );
       });
     });
 
-    // TODO(nweiz): This should use the server's error message once the client
-    // can parse the XML.
+    expect(
+      pub.stderr,
+      emits(
+        'Server error code: EntityTooLarge',
+      ),
+    );
+    expect(
+      pub.stderr,
+      emits(
+        'Server message: Your proposed upload is larger than the maximum object size specified in your Policy Document.',
+      ),
+    );
+    expect(
+      pub.stderr,
+      emits('Server details: Content-length exceeds upper bound on range'),
+    );
+
     expect(pub.stderr, emits('Failed to upload the package.'));
     await pub.shouldExit(1);
   });
diff --git a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
index e4da8be..f463833 100644
--- a/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
+++ b/test/testdata/goldens/directory_option_test/commands taking a --directory~-C parameter work.txt
@@ -118,6 +118,8 @@
 ├── lib
 │   └── test_pkg.dart (<1 KB)
 └── pubspec.yaml (<1 KB)
+
+Total compressed archive size: <1 KB.
 Validating package...
 The server may enforce additional checks.
 [STDERR] 
diff --git a/test/testdata/goldens/lish/many_files_test/displays all files.txt b/test/testdata/goldens/lish/many_files_test/displays all files.txt
index 82a9431..2d37456 100644
--- a/test/testdata/goldens/lish/many_files_test/displays all files.txt
+++ b/test/testdata/goldens/lish/many_files_test/displays all files.txt
@@ -29,6 +29,8 @@
 │   ├── file_9.dart (<1 KB)
 │   └── test_pkg.dart (<1 KB)
 └── pubspec.yaml (<1 KB)
+
+Total compressed archive size: <1 KB.
 Validating package...
 
 Publishing is forever; packages cannot be unpublished.
diff --git a/test/validator/size_test.dart b/test/validator/size_test.dart
index 02a093c..f04245a 100644
--- a/test/validator/size_test.dart
+++ b/test/validator/size_test.dart
@@ -11,30 +11,29 @@
 import '../test_pub.dart';
 import 'utils.dart';
 
-Future<void> expectSizeValidationError(Matcher matcher) async {
+Future<void> expectSizeValidationHint(Matcher matcher) async {
   await expectValidationDeprecated(
     SizeValidator.new,
     size: 100 * (1 << 20) + 1,
-    errors: contains(matcher),
+    hints: contains(matcher),
   );
 }
 
 void main() {
-  test('considers a package valid if it is <= 100 MB', () async {
+  test('ho hint if package is <= 100 MB', () async {
     await d.validPackage().create();
 
     await expectValidationDeprecated(SizeValidator.new, size: 100);
     await expectValidationDeprecated(SizeValidator.new, size: 100 * (1 << 20));
   });
 
-  group('considers a package invalid if it is more than 100 MB', () {
+  group('hints if package is more than 100 MB', () {
     test('package is not under source control and no .gitignore exists',
         () async {
       await d.validPackage().create();
 
-      await expectSizeValidationError(
-        equals('Your package is 100.0 MB. Hosted packages must '
-            'be smaller than 100 MB.'),
+      await expectSizeValidationHint(
+        contains('Your package is 100.0 MB.'),
       );
     });
 
@@ -42,9 +41,9 @@
       await d.validPackage().create();
       await d.dir(appPath, [d.file('.gitignore', 'ignored')]).create();
 
-      await expectSizeValidationError(
+      await expectSizeValidationHint(
         allOf(
-          contains('Hosted packages must be smaller than 100 MB.'),
+          contains('Your package is 100.0 MB.'),
           contains('Your .gitignore has no effect since your project '
               'does not appear to be in version control.'),
         ),
@@ -55,9 +54,9 @@
       await d.validPackage().create();
       await d.git(appPath).create();
 
-      await expectSizeValidationError(
+      await expectSizeValidationHint(
         allOf(
-          contains('Hosted packages must be smaller than 100 MB.'),
+          contains('Your package is 100.0 MB.'),
           contains('Consider adding a .gitignore to avoid including '
               'temporary files.'),
         ),
@@ -68,9 +67,8 @@
       await d.validPackage().create();
       await d.git(appPath, [d.file('.gitignore', 'ignored')]).create();
 
-      await expectSizeValidationError(
-        equals('Your package is 100.0 MB. Hosted packages must '
-            'be smaller than 100 MB.'),
+      await expectSizeValidationHint(
+        contains('Your package is 100.0 MB.'),
       );
     });
   });