New command `dart pub cache preload` (#3636)

diff --git a/lib/src/command/cache.dart b/lib/src/command/cache.dart
index 12c4fbf..43e8c5a 100644
--- a/lib/src/command/cache.dart
+++ b/lib/src/command/cache.dart
@@ -6,6 +6,7 @@
 import 'cache_add.dart';
 import 'cache_clean.dart';
 import 'cache_list.dart';
+import 'cache_preload.dart';
 import 'cache_repair.dart';
 
 /// Handles the `cache` pub command.
@@ -22,5 +23,8 @@
     addSubcommand(CacheListCommand());
     addSubcommand(CacheCleanCommand());
     addSubcommand(CacheRepairCommand());
+    addSubcommand(
+      CachePreloadCommand(),
+    );
   }
 }
diff --git a/lib/src/command/cache_preload.dart b/lib/src/command/cache_preload.dart
new file mode 100644
index 0000000..54b7867
--- /dev/null
+++ b/lib/src/command/cache_preload.dart
@@ -0,0 +1,49 @@
+// Copyright (c) 2014, 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.
+
+import 'dart:async';
+
+import '../command.dart';
+import '../io.dart';
+import '../log.dart' as log;
+import '../source/hosted.dart';
+import '../utils.dart';
+
+/// Handles the `cache preload` pub command.
+class CachePreloadCommand extends PubCommand {
+  @override
+  String get name => 'preload';
+  @override
+  String get description => 'Install packages from a .tar.gz archive.';
+  @override
+  String get argumentsDescription => '<package1.tar.gz> ...';
+  @override
+  String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-cache';
+
+  /// The `cache preload` command is hidden by default, because it's really only intended for
+  /// `flutter` to use when pre-loading `PUB_CACHE` after being installed from `zip` archive.
+  @override
+  bool get hidden => true;
+
+  @override
+  Future<void> runProtected() async {
+    // Make sure there is a package.
+    if (argResults.rest.isEmpty) {
+      usageException('No package to preload given.');
+    }
+
+    for (String packagePath in argResults.rest) {
+      if (!fileExists(packagePath)) {
+        fail('Could not find file $packagePath.');
+      }
+    }
+    for (String archivePath in argResults.rest) {
+      final id = await cache.hosted.preloadPackage(archivePath, cache);
+      final url = (id.description.description as HostedDescription).url;
+
+      final fromPart = HostedSource.isFromPubDev(id) ? '' : ' from $url';
+      log.message('Installed $archivePath in cache as $id$fromPart.');
+    }
+  }
+}
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 4e8a939..5922e3c 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -129,6 +129,11 @@
 
   static bool isPubDevUrl(String url) {
     final origin = Uri.parse(url).origin;
+    // Allow the defaultHostedUrl to be overriden when running from tests
+    if (runningFromTest &&
+        io.Platform.environment['_PUB_TEST_DEFAULT_HOSTED_URL'] != null) {
+      return origin == io.Platform.environment['_PUB_TEST_DEFAULT_HOSTED_URL'];
+    }
     return origin == pubDevUrl || origin == pubDartlangUrl;
   }
 
@@ -994,7 +999,7 @@
         versions.firstWhereOrNull((i) => i.version == id.version);
     final packageName = id.name;
     final version = id.version;
-    late Uint8List contentHash;
+    late final Uint8List contentHash;
     if (versionInfo == null) {
       throw PackageNotFoundException(
           'Package $packageName has no version $version');
@@ -1032,13 +1037,8 @@
 See $contentHashesDocumentationUrl.
 ''');
         }
-        final path = hashPath(id, cache);
-        ensureDir(p.dirname(path));
-        writeTextFile(
-          path,
-          hexEncode(actualHash.bytes),
-        );
         contentHash = Uint8List.fromList(actualHash.bytes);
+        writeHash(id, cache, contentHash);
       }
 
       // It is important that we do not compare against id.description.sha256,
@@ -1086,9 +1086,14 @@
       );
 
       var tempDir = cache.createTempDir();
-      await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
+      try {
+        await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
 
-      ensureDir(p.dirname(destPath));
+        ensureDir(p.dirname(destPath));
+      } catch (e) {
+        deleteEntry(tempDir);
+        rethrow;
+      }
       // Now that the get has succeeded, move it to the real location in the
       // cache.
       //
@@ -1100,6 +1105,84 @@
     });
   }
 
+  /// Writes the contenthash for [id] in the cache.
+  void writeHash(PackageId id, SystemCache cache, List<int> bytes) {
+    final path = hashPath(id, cache);
+    ensureDir(p.dirname(path));
+    writeTextFile(
+      path,
+      hexEncode(bytes),
+    );
+  }
+
+  /// Installs a tar.gz file in [archivePath] as if it was downloaded from a
+  /// package repository.
+  ///
+  /// The name, version and repository are decided from the pubspec.yaml that
+  /// must be present in the archive.
+  Future<PackageId> preloadPackage(
+      String archivePath, SystemCache cache) async {
+    // Extract to a temp-folder and do atomic rename to preserve the integrity
+    // of the cache.
+    late final Uint8List contentHash;
+
+    var tempDir = cache.createTempDir();
+    final PackageId id;
+    try {
+      try {
+        // We read the file twice, once to compute the hash, and once to extract
+        // the archive.
+        //
+        // It would be desirable to read the file only once, but the tar
+        // extraction closes the stream early making things tricky to get right.
+        contentHash = Uint8List.fromList(
+            (await sha256.bind(readBinaryFileAsStream(archivePath)).first)
+                .bytes);
+        await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
+      } on FormatException catch (e) {
+        dataError('Failed to extract `$archivePath`: ${e.message}.');
+      }
+      if (!fileExists(p.join(tempDir, 'pubspec.yaml'))) {
+        fail(
+            'Found no `pubspec.yaml` in $archivePath. Is it a valid pub package archive?');
+      }
+      final Pubspec pubspec;
+      try {
+        pubspec = Pubspec.load(tempDir, cache.sources);
+        final errors = pubspec.allErrors;
+        if (errors.isNotEmpty) {
+          throw errors.first;
+        }
+      } on Exception catch (e) {
+        fail('Failed to load `pubspec.yaml` from `$archivePath`: $e.');
+      }
+      // Reconstruct the PackageId from the extracted pubspec.yaml.
+      id = PackageId(
+        pubspec.name,
+        pubspec.version,
+        ResolvedHostedDescription(
+          HostedDescription(
+            pubspec.name,
+            validateAndNormalizeHostedUrl(cache.hosted.defaultUrl).toString(),
+          ),
+          sha256: contentHash,
+        ),
+      );
+    } catch (e) {
+      deleteEntry(tempDir);
+      rethrow;
+    }
+    final packageDir = getDirectoryInCache(id, cache);
+    if (dirExists(packageDir)) {
+      log.fine(
+          'Cache entry for ${id.name}-${id.version} already exists. Replacing.');
+      deleteEntry(packageDir);
+    }
+    tryRenameDir(tempDir, packageDir);
+    writeHash(id, cache, contentHash);
+    return id;
+  }
+
   /// When an error occurs trying to read something about [package] from [hostedUrl],
   /// this tries to translate into a more user friendly error message.
   ///
diff --git a/test/cache/preload_test.dart b/test/cache/preload_test.dart
new file mode 100644
index 0000000..3bf5f64
--- /dev/null
+++ b/test/cache/preload_test.dart
@@ -0,0 +1,180 @@
+// Copyright (c) 2013, 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.
+
+import 'dart:io';
+
+import 'package:http/http.dart';
+import 'package:path/path.dart' as p;
+import 'package:pub/src/exit_codes.dart';
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../descriptor.dart';
+import '../test_pub.dart';
+
+void main() {
+  test('adds correct entries to cache and stores the content-hash', () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+    server.serve('foo', '2.0.0');
+
+    await appDir({'foo': '^2.0.0'}).create();
+    // Do a `pub get` here to create a lock file in order to validate we later can
+    // `pub get --offline` with packages installed by `preload`.
+    await pubGet();
+
+    await runPub(args: ['cache', 'clean', '-f']);
+
+    final archivePath1 = p.join(sandbox, 'foo-1.0.0-archive.tar.gz');
+    final archivePath2 = p.join(sandbox, 'foo-2.0.0-archive.tar.gz');
+
+    File(archivePath1).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz')));
+    File(archivePath2).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/2.0.0.tar.gz')));
+    await runPub(
+      args: ['cache', 'preload', archivePath1, archivePath2],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output: allOf(
+        [
+          contains('Installed $archivePath1 in cache as foo 1.0.0.'),
+          contains('Installed $archivePath2 in cache as foo 2.0.0.'),
+        ],
+      ),
+    );
+    await d.cacheDir({'foo': '1.0.0'}).validate();
+    await d.cacheDir({'foo': '2.0.0'}).validate();
+
+    await hostedHashesCache([
+      file('foo-1.0.0.sha256', await server.peekArchiveSha256('foo', '1.0.0')),
+    ]).validate();
+
+    await hostedHashesCache([
+      file('foo-2.0.0.sha256', await server.peekArchiveSha256('foo', '2.0.0')),
+    ]).validate();
+
+    await pubGet(args: ['--offline']);
+  });
+
+  test(
+      'installs package according to PUB_HOSTED_URL even on non-offical server',
+      () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz')));
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      // By having pub.dev be the "official" server the test-server (localhost)
+      // is considered non-official. Test that the output mentions that we
+      // are installing to a non-official server.
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': 'pub.dev'},
+      output: allOf([
+        contains(
+            'Installed $archivePath in cache as foo 1.0.0 from ${server.url}.')
+      ]),
+    );
+    await d.cacheDir({'foo': '1.0.0'}).validate();
+  });
+
+  test('overwrites existing entry in cache', () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0', contents: [file('old-file.txt')]);
+
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output:
+          allOf([contains('Installed $archivePath in cache as foo 1.0.0.')]),
+    );
+
+    server.serve('foo', '1.0.0', contents: [file('new-file.txt')]);
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output:
+          allOf([contains('Installed $archivePath in cache as foo 1.0.0.')]),
+    );
+    await hostedCache([
+      dir('foo-1.0.0', [file('new-file.txt'), nothing('old-file.txt')])
+    ]).validate();
+  });
+
+  test('handles missing archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains('Could not find file $archivePath.'),
+      exitCode: 1,
+    );
+  });
+
+  test('handles broken archives', () async {
+    final archivePath = p.join(sandbox, 'archive');
+    File(archivePath).writeAsBytesSync('garbage'.codeUnits);
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error:
+          contains('Failed to extract `$archivePath`: Filter error, bad data.'),
+      exitCode: DATA,
+    );
+  });
+
+  test('handles missing pubspec.yaml in archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+
+    // Create a tar.gz with a single file (and no pubspec.yaml).
+    File(archivePath).writeAsBytesSync(
+      await tarFromDescriptors([d.file('foo.txt')]).expand((x) => x).toList(),
+    );
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains(
+        'Found no `pubspec.yaml` in $archivePath. Is it a valid pub package archive?',
+      ),
+      exitCode: 1,
+    );
+  });
+
+  test('handles broken pubspec.yaml in archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(
+        await tarFromDescriptors([d.file('pubspec.yaml', '{}')])
+            .expand((x) => x)
+            .toList());
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains(
+        'Failed to load `pubspec.yaml` from `$archivePath`: Error on line 1, column 1',
+      ),
+      exitCode: 1,
+    );
+  });
+}
diff --git a/test/package_server.dart b/test/package_server.dart
index 06a8d1b..d69d0ef 100644
--- a/test/package_server.dart
+++ b/test/package_server.dart
@@ -11,7 +11,6 @@
 import 'package:path/path.dart' as p;
 import 'package:pub/src/crc32c.dart';
 import 'package:pub/src/source/hosted.dart';
-import 'package:pub/src/third_party/tar/tar.dart';
 import 'package:pub/src/utils.dart' show hexEncode;
 import 'package:pub_semver/pub_semver.dart';
 import 'package:shelf/shelf.dart' as shelf;
@@ -239,60 +238,10 @@
     package.versions[version] = _ServedPackageVersion(
       pubspecFields,
       headers: headers,
-      contents: () {
-        final entries = <TarEntry>[];
-
-        void addDescriptor(d.Descriptor descriptor, String path) {
-          if (descriptor is d.DirectoryDescriptor) {
-            for (final e in descriptor.contents) {
-              addDescriptor(e, p.posix.join(path, descriptor.name));
-            }
-          } else {
-            entries.add(
-              TarEntry(
-                TarHeader(
-                  // Ensure paths in tar files use forward slashes
-                  name: p.posix.join(path, descriptor.name),
-                  // We want to keep executable bits, but otherwise use the default
-                  // file mode
-                  mode: 420,
-                  // size: 100,
-                  modified: DateTime.fromMicrosecondsSinceEpoch(0),
-                  userName: 'pub',
-                  groupName: 'pub',
-                ),
-                (descriptor as d.FileDescriptor).readAsBytes(),
-              ),
-            );
-          }
-        }
-
-        for (final e in contents ?? <d.Descriptor>[]) {
-          addDescriptor(e, '');
-        }
-        return _replaceOs(Stream.fromIterable(entries)
-            .transform(tarWriterWith(format: OutputFormat.gnuLongName))
-            .transform(gzip.encoder));
-      },
+      contents: () => tarFromDescriptors(contents ?? []),
     );
   }
 
-  /// Replaces the entry at index 9 in [stream] with a 0. This replaces the os
-  /// entry of a gzip stream, giving us the same stream and thius stable testing
-  /// on all platforms.
-  ///
-  /// See https://www.rfc-editor.org/rfc/rfc1952 section 2.3 for information
-  /// about the OS header.
-  Stream<List<int>> _replaceOs(Stream<List<int>> stream) async* {
-    final bytesBuilder = BytesBuilder();
-    await for (final t in stream) {
-      bytesBuilder.add(t);
-    }
-    final result = bytesBuilder.toBytes();
-    result[9] = 0;
-    yield result;
-  }
-
   // Mark a package discontinued.
   void discontinue(String name,
       {bool isDiscontinued = true, String? replacementText}) {
diff --git a/test/test_pub.dart b/test/test_pub.dart
index 58da455..6a2e67b 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -9,9 +9,10 @@
 /// library provides an API to build tests like that.
 import 'dart:convert';
 import 'dart:core';
-import 'dart:io';
+import 'dart:io' hide BytesBuilder;
 import 'dart:isolate';
 import 'dart:math';
+import 'dart:typed_data';
 
 import 'package:async/async.dart';
 import 'package:http/testing.dart';
@@ -26,6 +27,7 @@
 import 'package:pub/src/package_name.dart';
 import 'package:pub/src/source/hosted.dart';
 import 'package:pub/src/system_cache.dart';
+import 'package:pub/src/third_party/tar/tar.dart';
 import 'package:pub/src/utils.dart';
 import 'package:pub/src/validator.dart';
 import 'package:pub_semver/pub_semver.dart';
@@ -974,3 +976,54 @@
     'PATH': '$binFolder$separator${Platform.environment['PATH']}',
   };
 }
+
+Stream<List<int>> tarFromDescriptors(Iterable<d.Descriptor> contents) {
+  final entries = <TarEntry>[];
+  void addDescriptor(d.Descriptor descriptor, String path) {
+    if (descriptor is d.DirectoryDescriptor) {
+      for (final e in descriptor.contents) {
+        addDescriptor(e, p.posix.join(path, descriptor.name));
+      }
+    } else {
+      entries.add(
+        TarEntry(
+          TarHeader(
+            // Ensure paths in tar files use forward slashes
+            name: p.posix.join(path, descriptor.name),
+            // We want to keep executable bits, but otherwise use the default
+            // file mode
+            mode: 420,
+            // size: 100,
+            modified: DateTime.fromMicrosecondsSinceEpoch(0),
+            userName: 'pub',
+            groupName: 'pub',
+          ),
+          (descriptor as d.FileDescriptor).readAsBytes(),
+        ),
+      );
+    }
+  }
+
+  for (final e in contents) {
+    addDescriptor(e, '');
+  }
+  return _replaceOs(Stream.fromIterable(entries)
+      .transform(tarWriterWith(format: OutputFormat.gnuLongName))
+      .transform(gzip.encoder));
+}
+
+/// Replaces the entry at index 9 in [stream] with a 0. This replaces the os
+/// entry of a gzip stream, giving us the same stream and thius stable testing
+/// on all platforms.
+///
+/// See https://www.rfc-editor.org/rfc/rfc1952 section 2.3 for information
+/// about the OS header.
+Stream<List<int>> _replaceOs(Stream<List<int>> stream) async* {
+  final bytesBuilder = BytesBuilder();
+  await for (final t in stream) {
+    bytesBuilder.add(t);
+  }
+  final result = bytesBuilder.toBytes();
+  result[9] = 0;
+  yield result;
+}