Update to Dart 3, maintenance (#27)

* Update to Dart 3

* Run CI weekly

* Bump version in pubspec

* Might as well go stable

* Add doc comments

* Enforce docs for exported members
diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index fbdbf21..c76e653 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -4,7 +4,9 @@
   push:
     branches: [ main ]
   pull_request:
-    branches: [ main ]
+  schedule:
+    # Make sure everything is still working by running the CI weekly.
+    - cron: "0 5 * * 1"
 
 jobs:
   analyze:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a804552..50b4c7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.0.0
+
+- __Breaking__ Add class modifiers where applicable.
+
 ## 0.5.6
 
 - Allow cancelling a `TarEntry.contents` subscription before reading more files.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 8b18047..1dd563f 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,9 +1,6 @@
-include: package:extra_pedantic/analysis_options.2.0.0.yaml
+include: package:extra_pedantic/analysis_options.4.0.0.yaml
 
 analyzer:
-  strong-mode:
-    implicit-casts: false
-    implicit-dynamic: false
   language:
     strict-inference: true
     strict-raw-types: true
@@ -12,6 +9,7 @@
   rules:
     close_sinks: false # This rule has just too many false-positives...
     comment_references: true
+    package_api_docs: true
     literal_only_boolean_expressions: false # Nothing wrong with a little while(true)
     parameter_assignments: false
     unnecessary_await_in_return: false
diff --git a/lib/src/entry.dart b/lib/src/entry.dart
index 71d1730..7645bbb 100644
--- a/lib/src/entry.dart
+++ b/lib/src/entry.dart
@@ -1,7 +1,5 @@
 import 'dart:async';
 
-import 'package:meta/meta.dart';
-
 import 'header.dart';
 
 /// An entry in a tar file.
@@ -9,8 +7,7 @@
 /// Usually, tar entries are read from a stream, and they're bound to the stream
 /// from which they've been read. This means that they can only be read once,
 /// and that only one [TarEntry] is active at a time.
-@sealed
-class TarEntry {
+final class TarEntry {
   /// The parsed [TarHeader] of this tar entry.
   final TarHeader header;
 
@@ -58,7 +55,7 @@
 }
 
 /// A tar entry stored in memory.
-class SynchronousTarEntry extends TarEntry {
+final class SynchronousTarEntry extends TarEntry {
   /// The contents of this tar entry as a byte array.
   final List<int> data;
 
diff --git a/lib/src/exception.dart b/lib/src/exception.dart
index 3d9e614..fa1fc92 100644
--- a/lib/src/exception.dart
+++ b/lib/src/exception.dart
@@ -1,8 +1,14 @@
 import 'package:meta/meta.dart';
 
 /// An exception indicating that there was an issue parsing a `.tar` file.
-/// Intended to be seen by the user.
-class TarException extends FormatException {
+///
+/// The [message] contains reported from this exception contains details on the
+/// location of the parsing error.
+///
+/// This is the only exception that should be thrown by the `tar` package. Other
+/// exceptions are either a bug in this package or errors thrown as a response
+/// to API misuse.
+final class TarException extends FormatException {
   @internal
   TarException(String message) : super(message);
 
diff --git a/lib/src/format.dart b/lib/src/format.dart
index ef65ec9..b584f5c 100644
--- a/lib/src/format.dart
+++ b/lib/src/format.dart
@@ -1,49 +1,38 @@
-import 'package:meta/meta.dart';
-
-/// Handy map to help us translate [TarFormat] values to their names.
-/// Be sure to keep this consistent with the constant initializers in
-/// [TarFormat].
-const _formatNames = {
-  1: 'V7',
-  2: 'USTAR',
-  4: 'PAX',
-  8: 'GNU',
-  16: 'STAR',
-};
-
 /// Holds the possible TAR formats that a file could take.
 ///
-/// This library only supports the V7, USTAR, PAX, GNU, and STAR formats.
-@sealed
-class TarFormat {
+/// This library supports the V7, USTAR, PAX, GNU, and STAR formats. The
+/// [MaybeTarFormat] class generally describes any combination of those formats
+/// and represents that we don't know the exact format yet. As soon as we do
+/// know, the [TarFormat] enum represents the exact format of a header.
+sealed class MaybeTarFormat {
   /// The TAR formats are encoded in powers of two in [_value], such that we
   /// can refine our guess via bit operations as we discover more information
   /// about the TAR file.
   /// A value of 0 means that the format is invalid.
-  final int _value;
+  int get _value;
 
-  const TarFormat._internal(this._value);
-
-  @override
-  int get hashCode => _value;
-
-  @override
-  bool operator ==(Object other) {
-    if (other is! TarFormat) return false;
-
-    return _value == other._value;
+  factory MaybeTarFormat._(int value) {
+    return switch (value) {
+      1 => TarFormat.v7,
+      2 => TarFormat.ustar,
+      4 => TarFormat.pax,
+      8 => TarFormat.gnu,
+      16 => TarFormat.star,
+      final other => _MaybeTarFormat(other),
+    };
   }
 
-  @override
-  String toString() {
-    if (!isValid()) return 'Invalid';
-
-    final possibleNames = _formatNames.entries
-        .where((e) => _value & e.key != 0)
-        .map((e) => e.value);
-
-    return possibleNames.join(' or ');
-  }
+  /// Returns a new [MaybeTarFormat] that signifies that it can be either
+  /// `this` or [other]'s format.
+  ///
+  /// **Example:**
+  /// ```dart
+  /// TarFormat format = TarFormat.ustar | TarFormat.pax;
+  /// ```
+  ///
+  /// The above code would signify that we have limited `format` to either
+  /// the USTAR or PAX format, but need further information to refine the guess.
+  MaybeTarFormat operator |(TarFormat other);
 
   /// Returns if [other] is a possible resolution of `this`.
   ///
@@ -51,37 +40,7 @@
   /// enough information to determine if it is [TarFormat.ustar] or
   /// [TarFormat.pax], so either of them could be possible resolutions of
   /// `this`.
-  bool has(TarFormat other) => _value & other._value != 0;
-
-  /// Returns a new [TarFormat] that signifies that it can be either
-  /// `this` or [other]'s format.
-  ///
-  /// **Example:**
-  /// ```dart
-  /// TarFormat format = TarFormat.USTAR | TarFormat.PAX;
-  /// ```
-  ///
-  /// The above code would signify that we have limited `format` to either
-  /// the USTAR or PAX format, but need further information to refine the guess.
-  TarFormat operator |(TarFormat other) {
-    return mayBe(other);
-  }
-
-  /// Returns a new [TarFormat] that signifies that it can be either
-  /// `this` or [other]'s format.
-  ///
-  /// **Example:**
-  /// ```dart
-  /// TarFormat format = TarFormat.PAX;
-  /// format = format.mayBe(TarFormat.USTAR);
-  /// ```
-  ///
-  /// The above code would signify that we learnt that in addition to being a
-  /// PAX format, it could also be of the USTAR format.
-  TarFormat mayBe(TarFormat? other) {
-    if (other == null) return this;
-    return TarFormat._internal(_value | other._value);
-  }
+  bool has(MaybeTarFormat other);
 
   /// Returns a new [TarFormat] that signifies that it can only be [other]'s
   /// format.
@@ -98,16 +57,20 @@
   ///
   /// If `has(other) == false`, [mayOnlyBe] will result in an unknown
   /// [TarFormat].
-  TarFormat mayOnlyBe(TarFormat other) {
-    return TarFormat._internal(_value & other._value);
-  }
+  MaybeTarFormat mayOnlyBe(MaybeTarFormat other);
 
   /// Returns if this format might be valid.
   ///
   /// This returns true as well even if we have yet to fully determine what the
   /// format is.
-  bool isValid() => _value > 0;
+  bool get valid;
+}
 
+/// A fully resolved tar format.
+///
+/// When we know that a tar entry must use a specific format, this is represented
+/// with a value from this [TarFormat] enum.
+enum TarFormat implements MaybeTarFormat {
   /// Original Unix Version 7 (V7) AT&T tar tool prior to standardization.
   ///
   /// The structure of the V7 Header consists of the following:
@@ -132,7 +95,7 @@
   /// https://www.freebsd.org/cgi/man.cgi?query=tar&sektion=5&format=html
   /// https://www.gnu.org/software/tar/manual/html_chapter/tar_15.html#SEC188
   /// http://cdrtools.sourceforge.net/private/man/star/star.4.html
-  static const v7 = TarFormat._internal(1);
+  v7(1, 'V7'),
 
   /// USTAR (Unix Standard TAR) header format defined in POSIX.1-1988.
   ///
@@ -178,7 +141,7 @@
   /// https://www.freebsd.org/cgi/man.cgi?query=tar&sektion=5&format=html
   /// https://www.gnu.org/software/tar/manual/html_chapter/tar_15.html#SEC188
   ///	http://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html#tag_20_92_13_06
-  static const ustar = TarFormat._internal(2);
+  ustar(2, 'USTAR'),
 
   /// PAX header format defined in POSIX.1-2001.
   ///
@@ -196,7 +159,7 @@
   /// https://www.gnu.org/software/tar/manual/html_chapter/tar_15.html#SEC188
   /// http://cdrtools.sourceforge.net/private/man/star/star.4.html
   ///	http://pubs.opengroup.org/onlinepubs/009695399/utilities/pax.html
-  static const pax = TarFormat._internal(4);
+  pax(4, 'PAX'),
 
   /// GNU header format.
   ///
@@ -246,7 +209,7 @@
   ///
   /// Reference:
   ///	https://www.gnu.org/software/tar/manual/html_node/Standard.html
-  static const gnu = TarFormat._internal(8);
+  gnu(8, 'GNU'),
 
   /// Schily's TAR format, which is incompatible with USTAR.
   /// This does not cover STAR extensions to the PAX format; these fall under
@@ -284,5 +247,76 @@
   ///
   /// Reference:
   /// http://cdrtools.sourceforge.net/private/man/star/star.4.html
-  static const star = TarFormat._internal(16);
+  star(16, 'STAR'),
+  ;
+
+  @override
+  final int _value;
+
+  final String _name;
+
+  const TarFormat(this._value, this._name);
+
+  @override
+  bool get valid => true;
+
+  @override
+  MaybeTarFormat operator |(TarFormat other) {
+    return other == this ? this : _MaybeTarFormat(_value | other._value);
+  }
+
+  @override
+  bool has(MaybeTarFormat other) {
+    return other == this;
+  }
+
+  @override
+  MaybeTarFormat mayOnlyBe(MaybeTarFormat other) {
+    return MaybeTarFormat._(_value & other._value);
+  }
+
+  @override
+  String toString() => _name;
+}
+
+final class _MaybeTarFormat implements MaybeTarFormat {
+  // Note: We never represent a single tar format in a _MaybeTarFormat, these
+  // are represented in the TarFormat enum.
+  @override
+  final int _value;
+
+  const _MaybeTarFormat(this._value);
+
+  @override
+  int get hashCode => _value;
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! TarFormat) return false;
+
+    return _value == other._value;
+  }
+
+  @override
+  String toString() {
+    if (!valid) return 'Invalid';
+
+    return TarFormat.values.where(has).map((e) => e._name).join(' or ');
+  }
+
+  @override
+  bool has(MaybeTarFormat other) => _value & other._value != 0;
+
+  @override
+  bool get valid => _value != 0;
+
+  @override
+  MaybeTarFormat mayOnlyBe(MaybeTarFormat other) {
+    return MaybeTarFormat._(_value & other._value);
+  }
+
+  @override
+  MaybeTarFormat operator |(TarFormat other) {
+    return MaybeTarFormat._(_value | other._value);
+  }
 }
diff --git a/lib/src/header.dart b/lib/src/header.dart
index 2f35662..e18cddf 100644
--- a/lib/src/header.dart
+++ b/lib/src/header.dart
@@ -79,8 +79,7 @@
 ///
 /// A tar header stores meta-information about the matching tar entry, such as
 /// its name.
-@sealed
-abstract class TarHeader {
+sealed class TarHeader {
   /// Type of header entry. In the V7 TAR format, this field was known as the
   /// link flag.
   TypeFlag get typeFlag;
@@ -125,7 +124,11 @@
   int get devMinor;
 
   /// The TAR format of the header.
-  TarFormat get format;
+  ///
+  /// When this library is sure it knows the format of the tar entry, this will
+  /// be a [TarFormat] enum value. In other cases, a [MaybeTarFormat] could
+  /// represent multiple possible formats.
+  MaybeTarFormat get format;
 
   /// Checks if this header indicates that the file will have content.
   bool get hasContent {
@@ -226,11 +229,14 @@
   int devMinor;
 
   @override
-  TarFormat format;
+  MaybeTarFormat format;
 
   @override
   TypeFlag get typeFlag {
-    return internalTypeFlag == TypeFlag.regA ? TypeFlag.reg : internalTypeFlag;
+    return switch (internalTypeFlag) {
+      TypeFlag.regA => TypeFlag.reg, // normalize
+      final other => other,
+    };
   }
 
   /// This constructor is meant to help us deal with header-only headers (i.e.
@@ -281,7 +287,7 @@
       throw TarException.header('Indicates an invalid size of $size');
     }
 
-    if (format.isValid() && format != TarFormat.v7) {
+    if (format.valid && format != TarFormat.v7) {
       // If it's a valid header that is not of the v7 format, it will have the
       // USTAR fields
       header
@@ -370,7 +376,7 @@
 /// Checks that [rawHeader] represents a valid tar header based on the
 /// checksum, and then attempts to guess the specific format based
 /// on magic values. If the checksum fails, then an error is thrown.
-TarFormat _getFormat(Uint8List rawHeader) {
+MaybeTarFormat _getFormat(Uint8List rawHeader) {
   final checksum = rawHeader.readOctal(checksumOffset, checksumLength);
 
   // Modern TAR archives use the unsigned checksum, but we check the signed
diff --git a/lib/src/reader.dart b/lib/src/reader.dart
index cf10987..c167151 100644
--- a/lib/src/reader.dart
+++ b/lib/src/reader.dart
@@ -19,8 +19,7 @@
 /// It is designed to read from a stream and to spit out substreams for
 /// individual file contents in order to minimize the amount of memory needed
 /// to read each archive where possible.
-@sealed
-class TarReader implements StreamIterator<TarEntry> {
+final class TarReader implements StreamIterator<TarEntry> {
   final BlockReader _reader;
   final PaxHeaders _paxHeaders = PaxHeaders();
   final int _maxSpecialFileSize;
@@ -677,7 +676,7 @@
 }
 
 @internal
-class PaxHeaders extends UnmodifiableMapBase<String, String> {
+final class PaxHeaders extends UnmodifiableMapBase<String, String> {
   final Map<String, String> _globalHeaders = {};
   Map<String, String> _localHeaders = {};
 
@@ -873,7 +872,7 @@
 ///
 /// Draining this stream will set the [TarReader._currentStream] field back to
 /// null. There can only be one content stream at the time.
-class _CurrentEntryStream extends Stream<List<int>> {
+final class _CurrentEntryStream extends Stream<List<int>> {
   _EntryStreamState state = _EntryStreamState.preListen;
 
   final TarReader _reader;
@@ -1008,6 +1007,6 @@
       _listener.addError(
           TarException('Unexpected end of tar file'), StackTrace.current);
     }
-    _listener.close();
+    unawaited(_listener.close());
   }
 }
diff --git a/lib/src/sparse.dart b/lib/src/sparse.dart
index 35c0311..bb938d0 100644
--- a/lib/src/sparse.dart
+++ b/lib/src/sparse.dart
@@ -10,7 +10,7 @@
 /// [SparseEntry]s can represent either data or holes, and we can easily
 /// convert between the two if we know the size of the file, all the sparse
 /// data and all the sparse entries combined must give the full size.
-class SparseEntry {
+final class SparseEntry {
   final int offset;
   final int length;
 
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index e2bb5b6..1ea5506 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -241,7 +241,7 @@
 }
 
 /// An optimized reader reading 512-byte blocks from an input stream.
-class BlockReader {
+final class BlockReader {
   final Stream<List<int>> _input;
   StreamSubscription<List<int>>? _subscription;
   bool _isClosed = false;
@@ -300,8 +300,13 @@
       _outgoing = null;
       _pause();
 
+      // Scheduling this in a microtask becuase the stream controller is
+      // synchronous.
       scheduleMicrotask(() {
-        outgoing.close();
+        // We don't need to await this since the stream controller is not used
+        // afterwards, if there's a paused listener we don't really care about
+        // that.
+        unawaited(outgoing.close());
       });
       return true;
     } else if (outgoing.isPaused || outgoing.isClosed) {
@@ -395,8 +400,14 @@
     }
 
     _isClosed = true;
-    _subscription?.cancel();
-    outgoing.close();
+
+    // Can be unawated because this is an onDone callback of the subscription,
+    // the subscription is already complete and we're just cleaning up.
+    unawaited(_subscription?.cancel());
+
+    // Can be unawated because we're fully done here, we won't do anything else
+    // with the outgoing controller.
+    unawaited(outgoing.close());
   }
 
   void _subscribeOrResume() {
diff --git a/lib/src/writer.dart b/lib/src/writer.dart
index 5ff92b9..17cab90 100644
--- a/lib/src/writer.dart
+++ b/lib/src/writer.dart
@@ -9,7 +9,8 @@
 import 'header.dart';
 import 'utils.dart';
 
-class _WritingTransformer extends StreamTransformerBase<TarEntry, List<int>> {
+final class _WritingTransformer
+    extends StreamTransformerBase<TarEntry, List<int>> {
   final OutputFormat format;
 
   const _WritingTransformer(this.format);
@@ -19,7 +20,10 @@
     // sync because the controller proxies another stream
     final controller = StreamController<List<int>>(sync: true);
     controller.onListen = () {
-      stream.pipe(tarWritingSink(controller, format: format));
+      // Can be unawaited since it's the only thing done in onListen and since
+      // pipe is a terminal operation managing the remaining lifecycle of this
+      // stream controller.
+      unawaited(stream.pipe(tarWritingSink(controller, format: format)));
     };
 
     return controller.stream;
@@ -152,7 +156,7 @@
   gnuLongName,
 }
 
-class _WritingSink extends StreamSink<TarEntry> {
+final class _WritingSink extends StreamSink<TarEntry> {
   final StreamSink<List<int>> _output;
   final _SynchronousTarSink _synchronousWriter;
   bool _closed = false;
@@ -247,7 +251,7 @@
   return Uint8List(padding);
 }
 
-class _SynchronousTarConverter
+final class _SynchronousTarConverter
     extends Converter<SynchronousTarEntry, List<int>> {
   final OutputFormat format;
 
@@ -269,7 +273,7 @@
   }
 }
 
-class _SynchronousTarSink extends Sink<SynchronousTarEntry> {
+final class _SynchronousTarSink extends Sink<SynchronousTarEntry> {
   final OutputFormat _format;
   final Sink<List<int>> _output;
 
diff --git a/pubspec.yaml b/pubspec.yaml
index 7e6cd4e..3164d39 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,10 +1,10 @@
 name: tar
 description: Memory-efficient, streaming implementation of the tar file format
-version: 0.5.6
+version: 1.0.0
 repository: https://github.com/simolus3/tar/
 
 environment:
-  sdk: '>=2.12.0 <3.0.0'
+  sdk: '>=3.0.0 <4.0.0'
 
 dependencies:
   async: ^2.6.0
@@ -13,7 +13,7 @@
 
 dev_dependencies:
   charcode: ^1.2.0
-  extra_pedantic: ^3.0.0
+  extra_pedantic: ^4.0.0
   file: ^6.1.2
   node_io: ^2.1.0
   path: ^1.8.0
diff --git a/test/format_test.dart b/test/format_test.dart
new file mode 100644
index 0000000..c42d68f
--- /dev/null
+++ b/test/format_test.dart
@@ -0,0 +1,43 @@
+import 'package:tar/tar.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('operator |', () {
+    expect(TarFormat.gnu | TarFormat.star, _isFormat('GNU or STAR'));
+    expect(TarFormat.star | TarFormat.gnu, _isFormat('GNU or STAR'));
+
+    expect(TarFormat.v7 | TarFormat.pax | TarFormat.star,
+        _isFormat('V7 or PAX or STAR'));
+  });
+
+  test('has', () {
+    expect(TarFormat.gnu.has(TarFormat.gnu), isTrue);
+    expect(TarFormat.gnu.has(TarFormat.v7), isFalse);
+
+    expect(TarFormat.gnu.has(TarFormat.v7 | TarFormat.gnu), isFalse);
+    expect((TarFormat.v7 | TarFormat.gnu).has(TarFormat.gnu), isTrue);
+  });
+
+  test('mayOnlyBe', () {
+    expect(TarFormat.gnu.mayOnlyBe(TarFormat.v7), _isInvalid);
+    expect(TarFormat.gnu.mayOnlyBe(TarFormat.gnu), TarFormat.gnu);
+
+    expect((TarFormat.gnu | TarFormat.pax).mayOnlyBe(TarFormat.pax),
+        TarFormat.pax);
+    expect(
+      (TarFormat.gnu | TarFormat.pax | TarFormat.v7)
+          .mayOnlyBe(TarFormat.pax | TarFormat.v7),
+      _isFormat('V7 or PAX'),
+    );
+    expect((TarFormat.gnu | TarFormat.pax).mayOnlyBe(TarFormat.v7), _isInvalid);
+  });
+}
+
+TypeMatcher<MaybeTarFormat> _isFormat(String representation) {
+  return isA<MaybeTarFormat>()
+      .having((e) => e.toString(), 'toString()', representation);
+}
+
+TypeMatcher<MaybeTarFormat> get _isInvalid {
+  return _isFormat('Invalid').having((e) => e.valid, 'valid', isFalse);
+}
diff --git a/test/reader_test.dart b/test/reader_test.dart
index 75aa18c..11605b2 100644
--- a/test/reader_test.dart
+++ b/test/reader_test.dart
@@ -10,6 +10,8 @@
 
 import 'utils.dart';
 
+// ignore_for_file: discarded_futures
+
 void main() {
   group('POSIX.1-2001', () {
     test('reads files', () => _testWith('reference/posix.tar'));
diff --git a/test/sparse_test.dart b/test/sparse_test.dart
index 6729ae0..407f9d7 100644
--- a/test/sparse_test.dart
+++ b/test/sparse_test.dart
@@ -11,6 +11,8 @@
 
 import 'system_tar.dart';
 
+// ignore_for_file: discarded_futures
+
 /// Writes [size] random bytes to [path].
 Future<void> createTestFile(String path, int size) {
   final random = Random();
diff --git a/test/utils_test.dart b/test/utils_test.dart
index e9b3a8b..59b52ca 100644
--- a/test/utils_test.dart
+++ b/test/utils_test.dart
@@ -38,12 +38,13 @@
     const lengths = [024 * 1024 * 128 + 12, 12, 0];
 
     for (final length in lengths) {
-      test('with length $length', () {
+      test('with length $length', () async {
         final stream = zeroes(length);
 
         expect(
-          stream.fold<int>(0, (previous, element) => previous + element.length),
-          completion(length),
+          await stream.fold<int>(
+              0, (previous, element) => previous + element.length),
+          length,
         );
       });
     }
diff --git a/test/writer_test.dart b/test/writer_test.dart
index a026432..040d2fc 100644
--- a/test/writer_test.dart
+++ b/test/writer_test.dart
@@ -143,6 +143,7 @@
     void shouldThrow(tar.TarEntry entry) {
       final output = tar.tarWritingSink(_NullStreamSink(),
           format: tar.OutputFormat.gnuLongName);
+      // ignore: discarded_futures
       expect(Stream.value(entry).pipe(output), throwsA(isUnsupportedError));
     }