Add an archive descriptor (#21)


diff --git a/.travis.yml b/.travis.yml
index 415bad4..48803a1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,7 @@
 
 dart:
   - dev
-  - 2.0.0
+  - stable
 
 dart_task:
   - test
@@ -14,7 +14,7 @@
   - dart: dev
     dart_task:
       dartanalyzer: --fatal-infos --fatal-warnings .
-  - dart: 2.0.0
+  - dart: stable
     dart_task:
       dartanalyzer: --fatal-warnings .
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46908ef..a3b3529 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 1.2.0
+
+* Add an `ArchiveDescriptor` class and a corresponding `archive()` function that
+  can create and validate Zip and TAR archives.
+
 ## 1.1.1
 
 * Update to lowercase Dart core library constants.
diff --git a/lib/src/archive_descriptor.dart b/lib/src/archive_descriptor.dart
new file mode 100644
index 0000000..564958d
--- /dev/null
+++ b/lib/src/archive_descriptor.dart
@@ -0,0 +1,180 @@
+// 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;
+  }
+
+  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);
+
+  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;
+    }
+  }
+
+  Future<String> read() async => throw UnsupportedError(
+      "ArchiveDescriptor.read() is not supported. Use Archive.readAsBytes() "
+      "instead.");
+
+  Stream<List<int>> readAsBytes() => Stream.fromFuture(() async {
+        return _encodeFunction()(await archive);
+      }());
+
+  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.");
+    }
+  }
+
+  String describe() => describeDirectory(name, contents);
+}
diff --git a/lib/src/directory_descriptor.dart b/lib/src/directory_descriptor.dart
index d6ba56a..c505885 100644
--- a/lib/src/directory_descriptor.dart
+++ b/lib/src/directory_descriptor.dart
@@ -7,7 +7,6 @@
 
 import 'package:async/async.dart';
 import 'package:path/path.dart' as p;
-import 'package:term_glyph/term_glyph.dart' as glyph;
 import 'package:test/test.dart';
 
 import 'descriptor.dart';
@@ -122,23 +121,5 @@
     }));
   }
 
-  String describe() {
-    if (contents.isEmpty) return name;
-
-    var buffer = StringBuffer();
-    buffer.writeln(name);
-    for (var entry in contents.take(contents.length - 1)) {
-      var entryString =
-          prefixLines(entry.describe(), '${glyph.verticalLine}   ',
-              first: '${glyph.teeRight}${glyph.horizontalLine}'
-                  '${glyph.horizontalLine} ');
-      buffer.writeln(entryString);
-    }
-
-    var lastEntryString = prefixLines(contents.last.describe(), '    ',
-        first: '${glyph.bottomLeftCorner}${glyph.horizontalLine}'
-            '${glyph.horizontalLine} ');
-    buffer.write(lastEntryString);
-    return buffer.toString();
-  }
+  String describe() => describeDirectory(name, contents);
 }
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 9a14b70..5dad794 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -9,6 +9,7 @@
 import 'package:term_glyph/term_glyph.dart' as glyph;
 import 'package:test/test.dart';
 
+import 'descriptor.dart';
 import 'sandbox.dart';
 
 /// A UTF-8 codec that allows malformed byte sequences.
@@ -25,6 +26,27 @@
 /// Converts [strings] to a bulleted list.
 String bullet(Iterable<String> strings) => strings.map(addBullet).join("\n");
 
+/// Returns a human-readable description of a directory with the given [name]
+/// and [contents].
+String describeDirectory(String name, List<Descriptor> contents) {
+  if (contents.isEmpty) return name;
+
+  var buffer = StringBuffer();
+  buffer.writeln(name);
+  for (var entry in contents.take(contents.length - 1)) {
+    var entryString = prefixLines(entry.describe(), '${glyph.verticalLine}   ',
+        first: '${glyph.teeRight}${glyph.horizontalLine}'
+            '${glyph.horizontalLine} ');
+    buffer.writeln(entryString);
+  }
+
+  var lastEntryString = prefixLines(contents.last.describe(), '    ',
+      first: '${glyph.bottomLeftCorner}${glyph.horizontalLine}'
+          '${glyph.horizontalLine} ');
+  buffer.write(lastEntryString);
+  return buffer.toString();
+}
+
 /// Prepends each line in [text] with [prefix].
 ///
 /// If [first] or [last] is passed, the first and last lines, respectively, are
@@ -67,7 +89,7 @@
 
 /// Like [Future.wait] with `eagerError: true`, but reports errors after the
 /// first using [registerException] rather than silently ignoring them.
-Future waitAndReportErrors(Iterable<Future> futures) {
+Future<List<T>> waitAndReportErrors<T>(Iterable<Future<T>> futures) {
   var errored = false;
   return Future.wait(futures.map((future) {
     // Avoid async/await so that we synchronously add error handlers for the
diff --git a/lib/test_descriptor.dart b/lib/test_descriptor.dart
index cc33714..ea57645 100644
--- a/lib/test_descriptor.dart
+++ b/lib/test_descriptor.dart
@@ -5,6 +5,7 @@
 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';
@@ -12,6 +13,7 @@
 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';
@@ -72,5 +74,18 @@
 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 d39384e..b50a18b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test_descriptor
-version: 1.1.1
+version: 1.2.0
 description: An API for defining and verifying directory structures.
 author: Dart Team <misc@dartlang.org>
 homepage: https://github.com/dart-lang/test_descriptor
@@ -8,12 +8,14 @@
   sdk: '>=2.0.0 <3.0.0'
 
 dependencies:
-  async: '>=1.10.0 <3.0.0'
+  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'
   stack_trace: '^1.0.0'
-  test: '>=0.12.19 <2.0.0'
+  test: '^1.6.0'
   term_glyph: '^1.0.0'
 
 dev_dependencies:
diff --git a/test/archive_test.dart b/test/archive_test.dart
new file mode 100644
index 0000000..a413fb9
--- /dev/null
+++ b/test/archive_test.dart
@@ -0,0 +1,281 @@
+// 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;
+}