Migrate to null safety (#29)

Dropped support for archives as planned in https://github.com/dart-lang/test_descriptor/issues/25 - this also allows us to migrate before `package:archive`.

Closes https://github.com/dart-lang/test_descriptor/issues/25
diff --git a/.travis.yml b/.travis.yml
index 50bda8c..502b3eb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,6 @@
 
 dart:
   - dev
-  - 2.8.1
 
 dart_task:
   - test
@@ -14,9 +13,6 @@
   - dart: dev
     dart_task:
       dartanalyzer: --fatal-infos --fatal-warnings .
-  - dart: 2.8.1
-    dart_task:
-      dartanalyzer: --fatal-warnings .
 
 branches:
   only: [master]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d4156d..2c2bc32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,8 @@
-## 1.2.1-dev
+## 2.0.0-nullsafety
 
-* Update minimum Dart SDK to `2.7.0`.
+* Migrate to null safety.
 * Fix outdated URLs in `README.md`.
+* BREAKING: Removed archive support.
 
 ## 1.2.0
 
diff --git a/lib/src/archive_descriptor.dart b/lib/src/archive_descriptor.dart
deleted file mode 100644
index 7d96ce8..0000000
--- a/lib/src/archive_descriptor.dart
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright (c) 2019, 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 'dart:io';
-
-import 'package:archive/archive.dart';
-import 'package:async/async.dart';
-import 'package:meta/meta.dart';
-import 'package:path/path.dart' as p;
-import 'package:test/test.dart';
-
-import 'descriptor.dart';
-import 'directory_descriptor.dart';
-import 'file_descriptor.dart';
-import 'sandbox.dart';
-import 'utils.dart';
-
-/// A [Descriptor] describing files in a Tar or Zip archive.
-///
-/// The format is determined by the descriptor's file extension.
-@sealed
-class ArchiveDescriptor extends Descriptor implements FileDescriptor {
-  /// Descriptors for entries in this archive.
-  final List<Descriptor> contents;
-
-  /// Returns a `package:archive` [Archive] object that contains the contents of
-  /// this file.
-  Future<Archive> get archive async {
-    var archive = Archive();
-    (await _files(contents)).forEach(archive.addFile);
-    return archive;
-  }
-
-  @override
-  File get io => File(p.join(sandbox, name));
-
-  /// Returns [ArchiveFile]s for each file in [descriptors].
-  ///
-  /// If [parent] is passed, it's used as the parent directory for filenames.
-  Future<Iterable<ArchiveFile>> _files(Iterable<Descriptor> descriptors,
-      [String parent]) async {
-    return (await waitAndReportErrors(descriptors.map((descriptor) async {
-      var fullName =
-          parent == null ? descriptor.name : '$parent/${descriptor.name}';
-
-      if (descriptor is FileDescriptor) {
-        var bytes = await collectBytes(descriptor.readAsBytes());
-        return [
-          ArchiveFile(fullName, bytes.length, bytes)
-            // Setting the mode and mod time are necessary to work around
-            // brendan-duncan/archive#76.
-            ..mode = 428
-            ..lastModTime = DateTime.now().millisecondsSinceEpoch ~/ 1000
-        ];
-      } else if (descriptor is DirectoryDescriptor) {
-        return await _files(descriptor.contents, fullName);
-      } else {
-        throw UnsupportedError(
-            'An archive can only be created from FileDescriptors and '
-            'DirectoryDescriptors.');
-      }
-    })))
-        .expand((files) => files);
-  }
-
-  ArchiveDescriptor(String name, Iterable<Descriptor> contents)
-      : contents = List.unmodifiable(contents),
-        super(name);
-
-  @override
-  Future create([String parent]) async {
-    var path = p.join(parent ?? sandbox, name);
-    var file = File(path).openWrite();
-    try {
-      try {
-        await readAsBytes().listen(file.add).asFuture();
-      } finally {
-        await file.close();
-      }
-    } catch (_) {
-      await File(path).delete();
-      rethrow;
-    }
-  }
-
-  @override
-  Future<String> read() async => throw UnsupportedError(
-      'ArchiveDescriptor.read() is not supported. Use Archive.readAsBytes() '
-      'instead.');
-
-  @override
-  Stream<List<int>> readAsBytes() => Stream.fromFuture(() async {
-        return _encodeFunction()(await archive);
-      }());
-
-  @override
-  Future<void> validate([String parent]) async {
-    // Access this first so we eaerly throw an error for a path with an invalid
-    // extension.
-    var decoder = _decodeFunction();
-
-    var fullPath = p.join(parent ?? sandbox, name);
-    var pretty = prettyPath(fullPath);
-    if (!(await File(fullPath).exists())) {
-      fail('File not found: "$pretty".');
-    }
-
-    var bytes = await File(fullPath).readAsBytes();
-    Archive archive;
-    try {
-      archive = decoder(bytes);
-    } catch (_) {
-      // Catch every error to work around brendan-duncan/archive#77.
-      fail('File "$pretty" is not a valid archive.');
-    }
-
-    // Because validators expect to validate against a real filesystem, we have
-    // to extract the archive to a temp directory and run validation on that.
-    var tempDir = await Directory.systemTemp
-        .createTempSync('dart_test_')
-        .resolveSymbolicLinks();
-
-    try {
-      await waitAndReportErrors(archive.files.map((file) async {
-        var path = p.join(tempDir, file.name);
-        await Directory(p.dirname(path)).create(recursive: true);
-        await File(path).writeAsBytes(file.content as List<int>);
-      }));
-
-      await waitAndReportErrors(contents.map((entry) async {
-        try {
-          await entry.validate(tempDir);
-        } on TestFailure catch (error) {
-          // Replace the temporary directory with the path to the archive to
-          // make the error more user-friendly.
-          fail(error.message.replaceAll(tempDir, pretty));
-        }
-      }));
-    } finally {
-      await Directory(tempDir).delete(recursive: true);
-    }
-  }
-
-  /// Returns the function to use to encode this file to binary, based on its
-  /// [name].
-  List<int> Function(Archive) _encodeFunction() {
-    if (name.endsWith('.zip')) {
-      return ZipEncoder().encode;
-    } else if (name.endsWith('.tar')) {
-      return TarEncoder().encode;
-    } else if (name.endsWith('.tar.gz') ||
-        name.endsWith('.tar.gzip') ||
-        name.endsWith('.tgz')) {
-      return (archive) => GZipEncoder().encode(TarEncoder().encode(archive));
-    } else if (name.endsWith('.tar.bz2') || name.endsWith('.tar.bzip2')) {
-      return (archive) => BZip2Encoder().encode(TarEncoder().encode(archive));
-    } else {
-      throw UnsupportedError('Unknown file format $name.');
-    }
-  }
-
-  /// Returns the function to use to decode this file from binary, based on its
-  /// [name].
-  Archive Function(List<int>) _decodeFunction() {
-    if (name.endsWith('.zip')) {
-      return ZipDecoder().decodeBytes;
-    } else if (name.endsWith('.tar')) {
-      return TarDecoder().decodeBytes;
-    } else if (name.endsWith('.tar.gz') ||
-        name.endsWith('.tar.gzip') ||
-        name.endsWith('.tgz')) {
-      return (archive) =>
-          TarDecoder().decodeBytes(GZipDecoder().decodeBytes(archive));
-    } else if (name.endsWith('.tar.bz2') || name.endsWith('.tar.bzip2')) {
-      return (archive) =>
-          TarDecoder().decodeBytes(BZip2Decoder().decodeBytes(archive));
-    } else {
-      throw UnsupportedError('Unknown file format $name.');
-    }
-  }
-
-  @override
-  String describe() => describeDirectory(name, contents);
-}
diff --git a/lib/src/descriptor.dart b/lib/src/descriptor.dart
index cd8f7cb..0d6c30c 100644
--- a/lib/src/descriptor.dart
+++ b/lib/src/descriptor.dart
@@ -15,11 +15,11 @@
 
   /// Creates this entry within the [parent] directory, which defaults to
   /// [sandbox].
-  Future create([String parent]);
+  Future<void> create([String? parent]);
 
   /// Validates that the physical file system under [parent] (which defaults to
   /// [sandbox]) contains an entry that matches this descriptor.
-  Future validate([String parent]);
+  Future<void> validate([String? parent]);
 
   /// Returns a human-friendly tree-style description of this descriptor.
   String describe();
diff --git a/lib/src/directory_descriptor.dart b/lib/src/directory_descriptor.dart
index 1a8fe65..0927667 100644
--- a/lib/src/directory_descriptor.dart
+++ b/lib/src/directory_descriptor.dart
@@ -52,18 +52,18 @@
           }
           // Ignore broken symlinks.
           return null;
-        }).where((path) => path != null));
+        }).whereType<Descriptor>());
   }
 
   @override
-  Future create([String parent]) async {
+  Future<void> create([String? parent]) async {
     var fullPath = p.join(parent ?? sandbox, name);
     await Directory(fullPath).create(recursive: true);
     await Future.wait(contents.map((entry) => entry.create(fullPath)));
   }
 
   @override
-  Future validate([String parent]) async {
+  Future<void> validate([String? parent]) async {
     var fullPath = p.join(parent ?? sandbox, name);
     if (!(await Directory(fullPath).exists())) {
       fail('Directory not found: "${prettyPath(fullPath)}".');
@@ -80,7 +80,7 @@
   /// The [parents] parameter should only be passed by subclasses of
   /// [DirectoryDescriptor] that are recursively calling [load]. It's the
   /// URL-format path of the directories that have been loaded so far.
-  Stream<List<int>> load(url, [String parents]) {
+  Stream<List<int>> load(url, [String? parents]) {
     String path;
     if (url is String) {
       path = url;
diff --git a/lib/src/file_descriptor.dart b/lib/src/file_descriptor.dart
index 3d634b6..f5d9216 100644
--- a/lib/src/file_descriptor.dart
+++ b/lib/src/file_descriptor.dart
@@ -60,7 +60,7 @@
   FileDescriptor.protected(String name) : super(name);
 
   @override
-  Future create([String parent]) async {
+  Future<void> create([String? parent]) async {
     // Create the stream before we call [File.openWrite] because it may fail
     // fast (e.g. if this is a matcher file).
     var file = File(p.join(parent ?? sandbox, name)).openWrite();
@@ -72,7 +72,7 @@
   }
 
   @override
-  Future validate([String parent]) async {
+  Future<void> validate([String? parent]) async {
     var fullPath = p.join(parent ?? sandbox, name);
     var pretty = prettyPath(fullPath);
     if (!(await File(fullPath).exists())) {
@@ -87,7 +87,7 @@
   ///
   /// The [prettyPath] is a human-friendly representation of the path to the
   /// descriptor.
-  Future _validate(String prettyPath, List<int> binaryContents);
+  FutureOr<void> _validate(String prettyPath, List<int> binaryContents);
 
   /// Reads and decodes the contents of this descriptor as a UTF-8 string.
   ///
@@ -113,8 +113,8 @@
   Stream<List<int>> readAsBytes() => Stream.fromIterable([_contents]);
 
   @override
-  Future _validate(String prettPath, List<int> actualContents) async {
-    if (const IterableEquality().equals(_contents, actualContents)) return null;
+  Future<void> _validate(String prettPath, List<int> actualContents) async {
+    if (const IterableEquality().equals(_contents, actualContents)) return;
     // TODO(nweiz): show a hex dump here if the data is small enough.
     fail('File "$prettPath" didn\'t contain the expected binary data.');
   }
@@ -134,9 +134,9 @@
       Stream.fromIterable([utf8.encode(_contents)]);
 
   @override
-  Future _validate(String prettyPath, List<int> actualContents) {
+  void _validate(String prettyPath, List<int> actualContents) {
     var actualContentsText = utf8.decode(actualContents);
-    if (_contents == actualContentsText) return null;
+    if (_contents == actualContentsText) return;
     fail(_textMismatchMessage(prettyPath, _contents, actualContentsText));
   }
 
@@ -194,7 +194,7 @@
       throw UnsupportedError("Matcher files can't be created or read.");
 
   @override
-  Future _validate(String prettyPath, List<int> actualContents) async {
+  Future<void> _validate(String prettyPath, List<int> actualContents) async {
     try {
       expect(
           _isBinary ? actualContents : utf8.decode(actualContents), _matcher);
diff --git a/lib/src/nothing_descriptor.dart b/lib/src/nothing_descriptor.dart
index e70e890..22e7bc8 100644
--- a/lib/src/nothing_descriptor.dart
+++ b/lib/src/nothing_descriptor.dart
@@ -18,10 +18,10 @@
   NothingDescriptor(String name) : super(name);
 
   @override
-  Future create([String parent]) async {}
+  Future<void> create([String? parent]) async {}
 
   @override
-  Future validate([String parent]) async {
+  Future<void> validate([String? parent]) async {
     var fullPath = p.join(parent ?? sandbox, name);
     var pretty = prettyPath(fullPath);
     if (File(fullPath).existsSync()) {
diff --git a/lib/src/pattern_descriptor.dart b/lib/src/pattern_descriptor.dart
index 4c1760d..ed1b556 100644
--- a/lib/src/pattern_descriptor.dart
+++ b/lib/src/pattern_descriptor.dart
@@ -41,7 +41,7 @@
   /// in the constructor and validates the result. If exactly one succeeds,
   /// `this` is considered valid.
   @override
-  Future validate([String parent]) async {
+  Future<void> validate([String? parent]) async {
     var inSandbox = parent == null;
     parent ??= sandbox;
     var matchingEntries = await Directory(parent)
@@ -57,19 +57,22 @@
       fail('No entries found in $location matching $_patternDescription.');
     }
 
-    var results = await Future.wait(matchingEntries.map((entry) {
-      var basename = p.basename(entry);
-      return runZonedGuarded(() {
-        return Result.capture(Future.sync(() async {
-          await _fn(basename).validate(parent);
-          return basename;
-        }));
-      }, (_, __) {
-        // Validate may produce multiple errors, but we ignore all but the first
-        // to avoid cluttering the user with many different errors from many
-        // different un-matched entries.
-      });
-    }).toList());
+    var results = await Future.wait(matchingEntries
+        .map((entry) {
+          var basename = p.basename(entry);
+          return runZonedGuarded(() {
+            return Result.capture(Future.sync(() async {
+              await _fn(basename).validate(parent);
+              return basename;
+            }));
+          }, (_, __) {
+            // Validate may produce multiple errors, but we ignore all but the first
+            // to avoid cluttering the user with many different errors from many
+            // different un-matched entries.
+          });
+        })
+        .whereType<Future<Result<String>>>()
+        .toList());
 
     var successes = results.where((result) => result.isValue).toList();
     if (successes.isEmpty) {
@@ -77,7 +80,7 @@
     } else if (successes.length > 1) {
       fail('Multiple valid entries found in $location matching '
           '$_patternDescription:\n'
-          '${bullet(successes.map((result) => result.asValue.value))}');
+          '${bullet(successes.map((result) => result.asValue!.value))}');
     }
   }
 
@@ -96,7 +99,7 @@
   }
 
   @override
-  Future create([String parent]) {
+  Future<void> create([String? parent]) {
     throw UnsupportedError("Pattern descriptors don't support create().");
   }
 }
diff --git a/lib/src/sandbox.dart b/lib/src/sandbox.dart
index a09145e..1f5847f 100644
--- a/lib/src/sandbox.dart
+++ b/lib/src/sandbox.dart
@@ -13,23 +13,23 @@
 /// created the first time [sandbox] is accessed for each test case, and
 /// automatically deleted after the test finishes running.
 String get sandbox {
-  if (_sandbox != null) return _sandbox;
+  if (_sandbox != null) return _sandbox!;
   // Resolve symlinks so we don't end up with inconsistent paths on Mac OS where
   // /tmp is symlinked.
-  _sandbox = Directory.systemTemp
+  var sandbox = _sandbox = Directory.systemTemp
       .createTempSync('dart_test_')
       .resolveSymbolicLinksSync();
 
   addTearDown(() async {
-    var sandbox = _sandbox;
+    var sandbox = _sandbox!;
     _sandbox = null;
     await Directory(sandbox).delete(recursive: true);
   });
 
-  return _sandbox;
+  return sandbox;
 }
 
-String _sandbox;
+String? _sandbox;
 
 /// Whether [sandbox] has been created.
 bool get sandboxExists => _sandbox != null;
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 85d339f..d9d8017 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -54,10 +54,10 @@
 /// only a single line; otherwise, [first], [last], or [prefix] is used, in that
 /// order of precedence.
 String prefixLines(String text, String prefix,
-    {String first, String last, String single}) {
+    {String? first, String? last, String? single}) {
+  single ??= first ?? last ?? prefix;
   first ??= prefix;
   last ??= prefix;
-  single ??= first ?? last ?? prefix;
 
   var lines = text.split('\n');
   if (lines.length == 1) return '$single$text';
@@ -94,10 +94,10 @@
   return Future.wait(futures.map((future) {
     // Avoid async/await so that we synchronously add error handlers for the
     // futures to keep them from top-leveling.
-    return future.catchError((error, StackTrace stackTrace) {
+    return future.catchError((Object error, StackTrace stackTrace) {
       if (!errored) {
         errored = true;
-        throw error;
+        throw error; // ignore: only_throw_errors
       } else {
         registerException(error, stackTrace);
       }
diff --git a/lib/test_descriptor.dart b/lib/test_descriptor.dart
index 343a274..cf5e07f 100644
--- a/lib/test_descriptor.dart
+++ b/lib/test_descriptor.dart
@@ -5,7 +5,6 @@
 import 'package:path/path.dart' as p;
 import 'package:test/test.dart';
 
-import 'src/archive_descriptor.dart';
 import 'src/descriptor.dart';
 import 'src/directory_descriptor.dart';
 import 'src/file_descriptor.dart';
@@ -13,7 +12,6 @@
 import 'src/pattern_descriptor.dart';
 import 'src/sandbox.dart';
 
-export 'src/archive_descriptor.dart';
 export 'src/descriptor.dart';
 export 'src/directory_descriptor.dart';
 export 'src/file_descriptor.dart';
@@ -42,7 +40,7 @@
 /// children of the physical diretory, but it *doesn't* require that no other
 /// children exist. To ensure that a particular child doesn't exist, use
 /// [nothing].
-DirectoryDescriptor dir(String name, [Iterable<Descriptor> contents]) =>
+DirectoryDescriptor dir(String name, [Iterable<Descriptor>? contents]) =>
     DirectoryDescriptor(name, contents ?? <Descriptor>[]);
 
 /// Creates a new [NothingDescriptor] descriptor that asserts that no entry
@@ -72,21 +70,8 @@
 
 /// A convenience method for creating a [PatternDescriptor] descriptor that
 /// constructs a [DirectoryDescriptor] descriptor.
-PatternDescriptor dirPattern(Pattern name, [Iterable<Descriptor> contents]) =>
+PatternDescriptor dirPattern(Pattern name, [Iterable<Descriptor>? contents]) =>
     pattern(name, (realName) => dir(realName, contents));
 
-/// Creates a new [ArchiveDescriptor] with [name] and [contents].
-///
-/// [Descriptor.create] creates an archive with the given files and directories
-/// within it, and [Descriptor.validate] validates that the archive contains the
-/// given contents. It *doesn't* require that no other children exist. To ensure
-/// that a particular child doesn't exist, use [nothing].
-///
-/// The type of the archive is determined by [name]'s file extension. It
-/// supports `.zip`, `.tar`, `.tar.gz`/`.tar.gzip`/`.tgz`, and
-/// `.tar.bz2`/`.tar.bzip2` files.
-ArchiveDescriptor archive(String name, [Iterable<Descriptor> contents]) =>
-    ArchiveDescriptor(name, contents ?? []);
-
 /// Returns [path] within the [sandbox] directory.
 String path(String path) => p.join(sandbox, path);
diff --git a/pubspec.yaml b/pubspec.yaml
index 290888f..8ebd785 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,20 +1,19 @@
 name: test_descriptor
-version: 1.2.1-dev
+version: 2.0.0-nullsafety
 description: An API for defining and verifying directory structures.
 homepage: https://github.com/dart-lang/test_descriptor
 
 environment:
-  sdk: '>=2.8.1 <3.0.0'
+  sdk: '>=2.12.0-0 <3.0.0'
 
 dependencies:
-  archive: '^2.0.0'
-  async: '>=1.13.0 <3.0.0'
-  collection: '^1.5.0'
-  matcher: '^0.12.0'
-  meta: '^1.1.7'
-  path: '^1.0.0'
-  test: '^1.6.0'
-  term_glyph: '^1.0.0'
+  async: ^2.5.0-nullsafety
+  collection: ^1.15.0-nullsafety
+  matcher: ^0.12.10-nullsafety
+  meta: ^1.3.0-nullsafety
+  path: ^1.8.0-nullsafety
+  term_glyph: ^ 1.2.0-nullsafety
+  test: ^1.16.0-nullsafety
 
 dev_dependencies:
-  pedantic: ^1.0.0
+  pedantic: ^1.10.0-nullsafety
diff --git a/test/archive_test.dart b/test/archive_test.dart
deleted file mode 100644
index 751afdc..0000000
--- a/test/archive_test.dart
+++ /dev/null
@@ -1,281 +0,0 @@
-// Copyright (c) 2019, 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.
-
-@TestOn('vm')
-
-import 'dart:convert';
-import 'dart:io';
-
-import 'package:archive/archive.dart';
-import 'package:async/async.dart';
-import 'package:path/path.dart' as p;
-import 'package:test/test.dart';
-import 'package:test_descriptor/test_descriptor.dart' as d;
-
-import 'utils.dart';
-
-void main() {
-  group('create()', () {
-    test('creates an empty archive', () async {
-      await d.archive('test.tar').create();
-
-      var archive =
-          TarDecoder().decodeBytes(File(d.path('test.tar')).readAsBytesSync());
-      expect(archive.files, isEmpty);
-    });
-
-    test('creates an archive with files', () async {
-      await d.archive('test.tar', [
-        d.file('file1.txt', 'contents 1'),
-        d.file('file2.txt', 'contents 2')
-      ]).create();
-
-      var files = TarDecoder()
-          .decodeBytes(File(d.path('test.tar')).readAsBytesSync())
-          .files;
-      expect(files.length, equals(2));
-      _expectFile(files[0], 'file1.txt', 'contents 1');
-      _expectFile(files[1], 'file2.txt', 'contents 2');
-    });
-
-    test('creates an archive with files in a directory', () async {
-      await d.archive('test.tar', [
-        d.dir('dir', [
-          d.file('file1.txt', 'contents 1'),
-          d.file('file2.txt', 'contents 2')
-        ])
-      ]).create();
-
-      var files = TarDecoder()
-          .decodeBytes(File(d.path('test.tar')).readAsBytesSync())
-          .files;
-      expect(files.length, equals(2));
-      _expectFile(files[0], 'dir/file1.txt', 'contents 1');
-      _expectFile(files[1], 'dir/file2.txt', 'contents 2');
-    });
-
-    test('creates an archive with files in a nested directory', () async {
-      await d.archive('test.tar', [
-        d.dir('dir', [
-          d.dir('subdir', [
-            d.file('file1.txt', 'contents 1'),
-            d.file('file2.txt', 'contents 2')
-          ])
-        ])
-      ]).create();
-
-      var files = TarDecoder()
-          .decodeBytes(File(d.path('test.tar')).readAsBytesSync())
-          .files;
-      expect(files.length, equals(2));
-      _expectFile(files[0], 'dir/subdir/file1.txt', 'contents 1');
-      _expectFile(files[1], 'dir/subdir/file2.txt', 'contents 2');
-    });
-
-    group('creates a file in', () {
-      test('zip format', () async {
-        await d.archive('test.zip', [d.file('file.txt', 'contents')]).create();
-
-        var archive = ZipDecoder()
-            .decodeBytes(File(d.path('test.zip')).readAsBytesSync());
-        _expectFile(archive.files.single, 'file.txt', 'contents');
-      });
-
-      group('gzip tar format', () {
-        for (var extension in ['.tar.gz', '.tar.gzip', '.tgz']) {
-          test('with $extension', () async {
-            await d.archive(
-                'test$extension', [d.file('file.txt', 'contents')]).create();
-
-            var archive = TarDecoder().decodeBytes(GZipDecoder()
-                .decodeBytes(File(d.path('test$extension')).readAsBytesSync()));
-            _expectFile(archive.files.single, 'file.txt', 'contents');
-          });
-        }
-      });
-
-      group('bzip2 tar format', () {
-        for (var extension in ['.tar.bz2', '.tar.bzip2']) {
-          test('with $extension', () async {
-            await d.archive(
-                'test$extension', [d.file('file.txt', 'contents')]).create();
-
-            var archive = TarDecoder().decodeBytes(BZip2Decoder()
-                .decodeBytes(File(d.path('test$extension')).readAsBytesSync()));
-            _expectFile(archive.files.single, 'file.txt', 'contents');
-          });
-        }
-      });
-    });
-
-    group('gracefully rejects', () {
-      test('an uncreatable descriptor', () async {
-        await expectLater(
-            d.archive('test.tar', [d.filePattern(RegExp(r'^foo-'))]).create(),
-            throwsUnsupportedError);
-        await d.nothing('test.tar').validate();
-      });
-
-      test('a non-file non-directory descriptor', () async {
-        await expectLater(
-            d.archive('test.tar', [d.nothing('file.txt')]).create(),
-            throwsUnsupportedError);
-        await d.nothing('test.tar').validate();
-      });
-
-      test('an unknown file extension', () async {
-        await expectLater(
-            d.archive('test.asdf', [d.nothing('file.txt')]).create(),
-            throwsUnsupportedError);
-      });
-    });
-  });
-
-  group('validate()', () {
-    group('with an empty archive', () {
-      test('succeeds if an empty archive exists', () async {
-        File(d.path('test.tar'))
-            .writeAsBytesSync(TarEncoder().encode(Archive()));
-        await d.archive('test.tar').validate();
-      });
-
-      test('succeeds if a non-empty archive exists', () async {
-        File(d.path('test.tar')).writeAsBytesSync(
-            TarEncoder().encode(Archive()..addFile(_file('file.txt'))));
-        await d.archive('test.tar').validate();
-      });
-
-      test('fails if no archive exists', () {
-        expect(d.archive('test.tar').validate(),
-            throwsA(toString(startsWith('File not found: "test.tar".'))));
-      });
-
-      test('fails if an invalid archive exists', () {
-        d.file('test.tar', 'not a valid tar file').create();
-        expect(
-            d.archive('test.tar').validate(),
-            throwsA(toString(
-                startsWith('File "test.tar" is not a valid archive.'))));
-      });
-    });
-
-    test('succeeds if an archive contains a matching file', () async {
-      File(d.path('test.tar')).writeAsBytesSync(TarEncoder()
-          .encode(Archive()..addFile(_file('file.txt', 'contents'))));
-      await d.archive('test.tar', [d.file('file.txt', 'contents')]).validate();
-    });
-
-    test("fails if an archive doesn't contain a file", () async {
-      File(d.path('test.tar')).writeAsBytesSync(TarEncoder().encode(Archive()));
-      expect(
-          d.archive('test.tar', [d.file('file.txt', 'contents')]).validate(),
-          throwsA(
-              toString(startsWith('File not found: "test.tar/file.txt".'))));
-    });
-
-    test('fails if an archive contains a non-matching file', () async {
-      File(d.path('test.tar')).writeAsBytesSync(TarEncoder()
-          .encode(Archive()..addFile(_file('file.txt', 'wrong contents'))));
-      expect(
-          d.archive('test.tar', [d.file('file.txt', 'contents')]).validate(),
-          throwsA(toString(
-              startsWith('File "test.tar/file.txt" should contain:'))));
-    });
-
-    test('succeeds if an archive contains a file matching a pattern', () async {
-      File(d.path('test.tar')).writeAsBytesSync(TarEncoder()
-          .encode(Archive()..addFile(_file('file.txt', 'contents'))));
-      await d.archive('test.tar',
-          [d.filePattern(RegExp(r'f..e\.txt'), 'contents')]).validate();
-    });
-
-    group('validates a file in', () {
-      test('zip format', () async {
-        File(d.path('test.zip')).writeAsBytesSync(ZipEncoder()
-            .encode(Archive()..addFile(_file('file.txt', 'contents'))));
-
-        await d
-            .archive('test.zip', [d.file('file.txt', 'contents')]).validate();
-      });
-
-      group('gzip tar format', () {
-        for (var extension in ['.tar.gz', '.tar.gzip', '.tgz']) {
-          test('with $extension', () async {
-            File(d.path('test$extension')).writeAsBytesSync(GZipEncoder()
-                .encode(TarEncoder().encode(
-                    Archive()..addFile(_file('file.txt', 'contents')))));
-
-            await d.archive(
-                'test$extension', [d.file('file.txt', 'contents')]).validate();
-          });
-        }
-      });
-
-      group('bzip2 tar format', () {
-        for (var extension in ['.tar.bz2', '.tar.bzip2']) {
-          test('with $extension', () async {
-            File(d.path('test$extension')).writeAsBytesSync(BZip2Encoder()
-                .encode(TarEncoder().encode(
-                    Archive()..addFile(_file('file.txt', 'contents')))));
-
-            await d.archive(
-                'test$extension', [d.file('file.txt', 'contents')]).validate();
-          });
-        }
-      });
-    });
-
-    test('gracefully rejects an unknown file format', () {
-      expect(d.archive('test.asdf').validate(), throwsUnsupportedError);
-    });
-  });
-
-  test('read() is unsupported', () {
-    expect(d.archive('test.tar').read(), throwsUnsupportedError);
-  });
-
-  test('readAsBytes() returns the contents of the archive', () async {
-    var descriptor = d.archive('test.tar',
-        [d.file('file1.txt', 'contents 1'), d.file('file2.txt', 'contents 2')]);
-
-    var files = TarDecoder()
-        .decodeBytes(await collectBytes(descriptor.readAsBytes()))
-        .files;
-    expect(files.length, equals(2));
-    _expectFile(files[0], 'file1.txt', 'contents 1');
-    _expectFile(files[1], 'file2.txt', 'contents 2');
-  });
-
-  test('archive returns the in-memory contents', () async {
-    var archive = await d.archive('test.tar', [
-      d.file('file1.txt', 'contents 1'),
-      d.file('file2.txt', 'contents 2')
-    ]).archive;
-
-    var files = archive.files;
-    expect(files.length, equals(2));
-    _expectFile(files[0], 'file1.txt', 'contents 1');
-    _expectFile(files[1], 'file2.txt', 'contents 2');
-  });
-
-  test('io refers to the file within the sandbox', () {
-    expect(d.file('test.tar').io.path, equals(p.join(d.sandbox, 'test.tar')));
-  });
-}
-
-/// Asserts that [file] has the given [name] and [contents].
-void _expectFile(ArchiveFile file, String name, String contents) {
-  expect(file.name, equals(name));
-  expect(utf8.decode(file.content as List<int>), equals(contents));
-}
-
-/// Creates an [ArchiveFile] with the given [name] and [contents].
-ArchiveFile _file(String name, [String contents]) {
-  var bytes = utf8.encode(contents ?? '');
-  return ArchiveFile(name, bytes.length, bytes)
-    // Setting the mode and mod time are necessary to work around
-    // brendan-duncan/archive#76.
-    ..mode = 428
-    ..lastModTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-}
diff --git a/test/directory_test.dart b/test/directory_test.dart
index e135086..c669be8 100644
--- a/test/directory_test.dart
+++ b/test/directory_test.dart
@@ -211,7 +211,7 @@
   });
 
   group('describe()', () {
-    bool oldAscii;
+    late bool oldAscii;
     setUpAll(() {
       oldAscii = term_glyph.ascii;
       term_glyph.ascii = true;
diff --git a/test/sandbox_test.dart b/test/sandbox_test.dart
index f20a8ff..991bd7f 100644
--- a/test/sandbox_test.dart
+++ b/test/sandbox_test.dart
@@ -17,7 +17,7 @@
   });
 
   test('the directory is deleted after the test', () {
-    String sandbox;
+    late String sandbox;
     addTearDown(() {
       expect(Directory(sandbox).existsSync(), isFalse);
     });