Handle malformatted content-hashes in cache, version listing or pubspec.lock (#3818)

diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 53330fd..d790893 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -270,11 +270,29 @@
       version,
       ResolvedHostedDescription(
         HostedDescription(name, url),
-        sha256: sha256 == null ? null : hexDecode(sha256),
+        sha256: _parseContentHash(sha256),
       ),
     );
   }
 
+  /// Decodes a sha256 hash from a lock-file or package-listing.
+  /// It is expected to be a hex-encoded String of length 64.
+  ///
+  /// Throws a [FormatException] if the string cannot be decoded.
+  Uint8List? _parseContentHash(String? encoded) {
+    if (encoded == null) return null;
+    if (encoded.length != 64) {
+      throw FormatException('Content-hash has incorrect length');
+    }
+    try {
+      return hexDecode(encoded);
+    } on FormatException catch (e) {
+      return throw FormatException(
+        'Badly formatted content-hash: ${e.message}',
+      );
+    }
+  }
+
   /// Parses the description for a package.
   ///
   /// If the package parses correctly, this returns a (name, url) pair. If not,
@@ -390,7 +408,7 @@
         pubspec,
         Uri.parse(archiveUrl),
         status,
-        archiveSha256 == null ? null : hexDecode(archiveSha256),
+        _parseContentHash(archiveSha256),
       );
     }).toList();
   }
@@ -893,9 +911,12 @@
   /// Loads the hash at `hashPath(id)`.
   Uint8List? sha256FromCache(PackageId id, SystemCache cache) {
     try {
-      return hexDecode(readTextFile(hashPath(id, cache)));
+      return _parseContentHash(readTextFile(hashPath(id, cache)));
     } on io.IOException {
       return null;
+    } on FormatException catch (e) {
+      log.fine('Bad content-hash in cache: $e, ignoring cache entry');
+      return null;
     }
   }
 
diff --git a/test/content_hash_test.dart b/test/content_hash_test.dart
index bd106f4..074e167 100644
--- a/test/content_hash_test.dart
+++ b/test/content_hash_test.dart
@@ -245,4 +245,29 @@
       originalContentHash,
     );
   });
+
+  test('Badly formatted hash - warning and redownload', () async {
+    final server = await servePackages();
+    server.serveContentHashes = true;
+    server.serve('foo', '1.0.0');
+    await appDir(dependencies: {'foo': 'any'}).create();
+    await pubGet();
+    final lockfile = loadYaml(
+      File(p.join(sandbox, appPath, 'pubspec.lock')).readAsStringSync(),
+    );
+    final originalHash = lockfile['packages']['foo']['description']['sha256'];
+    await hostedHashesCache([
+      file(
+        'foo-1.0.0.sha256',
+        'e',
+      ),
+    ]).create();
+
+    await pubGet(
+      warning: 'Cached version of foo-1.0.0 has wrong hash - redownloading.',
+    );
+    await hostedHashesCache([
+      file('foo-1.0.0.sha256', originalHash),
+    ]).validate();
+  });
 }
diff --git a/test/lock_file_test.dart b/test/lock_file_test.dart
index 5fda64c..ce76003 100644
--- a/test/lock_file_test.dart
+++ b/test/lock_file_test.dart
@@ -322,6 +322,32 @@
         expectComesFromPubDev('retry');
       });
 
+      test('Complains about malformed content-hashes', () {
+        expect(
+          () => LockFile.parse(
+            '''
+packages:
+  retry:
+    dependency: transitive
+    description:
+      name: retry
+      url: "https://pub.dev"
+      sha256: abc # Not long enough
+    source: hosted
+    version: "1.0.0"
+''',
+            sources,
+          ),
+          throwsA(
+            isA<FormatException>().having(
+              (e) => e.message,
+              'message',
+              contains('Content-hash has incorrect length'),
+            ),
+          ),
+        );
+      });
+
       test('ignores extra stuff in file', () {
         LockFile.parse(
           '''