Add option to disallow trailing data (closes #11)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bda04b..61b3a02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## 0.3.1
+
+- Add `disallowTrailingData` parameter to `TarReader`. When the option is set,
+  `readNext` will ensure that the input stream does not emit further data after
+  the tar archive has been read fully.
+
 ## 0.3.0
 
 - Remove outdated references in the documentation
diff --git a/lib/src/reader.dart b/lib/src/reader.dart
index 600c1ca..f4b356f 100644
--- a/lib/src/reader.dart
+++ b/lib/src/reader.dart
@@ -59,8 +59,26 @@
   ///  - an error was emitted through a tar entry's content stream
   bool _isDone = false;
 
+  /// Whether we should ensure that the stream emits no further data after the
+  /// end of the tar file was reached.
+  final bool _checkNoTrailingData;
+
   /// Creates a tar reader reading from the raw [tarStream].
   ///
+  /// The [disallowTrailingData] parameter can be enabled to assert that the
+  /// [tarStream] contains exactly one tar archive before ending.
+  /// When [disallowTrailingData] is disabled (which is the default), the reader
+  /// will automatically cancel its stream subscription when [moveNext] returns
+  /// `false`.
+  /// When it is enabled and a marker indicating the end of an archive is
+  /// encountered, [moveNext] will wait for further events on the stream. If
+  /// further data is received, a [TarException] will be thrown and the
+  /// subscription will be cancelled. Otherwise, [moveNext] effectively waits
+  /// for a done event, making a cancellation unecessary.
+  /// Depending on the input stream, cancellations may cause unintended
+  /// side-effects. In that case, [disallowTrailingData] can be used to ensure
+  /// that the stream is only cancelled if it emits an invalid tar file.
+  ///
   /// The [maxSpecialFileSize] parameter can be used to limit the maximum length
   /// of hidden entries in the tar stream. These entries include extended PAX
   /// headers or long names in GNU tar. The content of those entries has to be
@@ -68,8 +86,10 @@
   /// avoid memory-based denial-of-service attacks, this library limits their
   /// maximum length. Changing the default of 2 KiB is rarely necessary.
   TarReader(Stream<List<int>> tarStream,
-      {int maxSpecialFileSize = defaultSpecialLength})
+      {int maxSpecialFileSize = defaultSpecialLength,
+      bool disallowTrailingData = false})
       : _chunkedStream = ChunkedStreamIterator(tarStream),
+        _checkNoTrailingData = disallowTrailingData,
         _maxSpecialFileSize = maxSpecialFileSize;
 
   @override
@@ -125,11 +145,12 @@
 
     HeaderImpl? nextHeader;
 
-    /// Externally, [next] iterates through the tar archive as if it is a series
-    /// of files. Internally, the tar format often uses fake "files" to add meta
-    /// data that describes the next file. These meta data "files" should not
-    /// normally be visible to the outside. As such, this loop iterates through
-    /// one or more "header files" until it finds a "normal file".
+    // Externally, [moveNext] iterates through the tar archive as if it is a
+    // series of files. Internally, the tar format often uses fake "files" to
+    // add meta data that describes the next file. These meta data "files"
+    // should not normally be visible to the outside. As such, this loop
+    // iterates through one or more "header files" until it finds a
+    // "normal file".
     while (true) {
       if (_skipNext > 0) {
         await _readFullBlock(_skipNext);
@@ -142,7 +163,7 @@
       nextHeader = await _readHeader(rawHeader);
       if (nextHeader == null) {
         if (eofAcceptable) {
-          await cancel();
+          await _handleExpectedEof();
           return false;
         } else {
           _unexpectedEof();
@@ -224,6 +245,8 @@
     _listenedToContentsOnce = false;
     _isReadingHeaders = false;
 
+    // Note: Calling cancel is safe when the stream has already been completed.
+    // It's a noop in that case, which is what we want.
     return _chunkedStream.cancel();
   }
 
@@ -286,6 +309,20 @@
     return size;
   }
 
+  Future<void> _handleExpectedEof() async {
+    if (_checkNoTrailingData) {
+      // This will be empty iff the stream is done
+      final furtherData = await _chunkedStream.read(1);
+
+      // Note that throwing will automatically cancel the reader
+      if (furtherData.isNotEmpty) {
+        throw TarException('Illegal content after the end of the tar archive.');
+      }
+    }
+
+    await cancel();
+  }
+
   Never _unexpectedEof() {
     throw TarException.header('Unexpected end of file');
   }
diff --git a/test/reader_test.dart b/test/reader_test.dart
index 3a9a3d8..a1e2ba4 100644
--- a/test/reader_test.dart
+++ b/test/reader_test.dart
@@ -71,7 +71,7 @@
       final zeroBlock = Uint8List(512);
       final controller = StreamController<List<int>>();
       controller.onListen = () {
-        controller..add(zeroBlock)..add(zeroBlock);
+        controller..add(zeroBlock)..add(zeroBlock)..add(zeroBlock);
       };
 
       final reader = TarReader(controller.stream);
@@ -114,6 +114,54 @@
     });
   });
 
+  group('disallowTrailingData: true', () {
+    final emptyBlock = Uint8List(512);
+
+    void closeLater(StreamController<Object?> controller) {
+      controller.onCancel = () => fail('Should not cancel stream subscription');
+
+      Timer.run(() {
+        // Calling close() implicitly cancels the stream subscription, we only
+        // want to ensure that the reader is not doing that.
+        controller.onCancel = null;
+        expect(controller.hasListener, isTrue,
+            reason: 'Should have a listener');
+
+        controller.close();
+      });
+    }
+
+    test('throws for trailing data', () async {
+      final input = StreamController<Uint8List>()
+        ..add(emptyBlock)
+        ..add(emptyBlock)
+        ..add(Uint8List(1)); // illegal content after end marker
+
+      final reader = TarReader(input.stream, disallowTrailingData: true);
+      await expectLater(reader.moveNext(), throwsA(isA<TarException>()));
+      expect(input.hasListener, isFalse,
+          reason: 'Should have cancelled subscription after error.');
+    });
+
+    test('does not throw or cancel if the stream ends as expected', () {
+      final input = StreamController<Uint8List>()
+        ..add(emptyBlock)
+        ..add(emptyBlock);
+      closeLater(input);
+
+      final reader = TarReader(input.stream, disallowTrailingData: true);
+      expectLater(reader.moveNext(), completion(isFalse));
+    });
+
+    test('does not throw or cancel if the stream ends without marker', () {
+      final input = StreamController<Uint8List>();
+      closeLater(input);
+
+      final reader = TarReader(input.stream, disallowTrailingData: true);
+      expectLater(reader.moveNext(), completion(isFalse));
+    });
+  });
+
   group('tests from dart-neats PR', () {
     Stream<List<int>> open(String name) {
       return File('reference/neats_test/$name').openRead();