Fix handling of directories in archives  (#4306)

diff --git a/lib/src/io.dart b/lib/src/io.dart
index 730962b..521632f 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -1072,12 +1072,14 @@
       throw FormatException('Tar file contained duplicate path ${entry.name}');
     }
 
-    if (!p.isWithin(destination, filePath)) {
+    if (!(p.isWithin(destination, filePath) ||
+        // allow including '.' as an entry in the tar.gz archive.
+        (entry.type == TypeFlag.dir && p.equals(destination, filePath)))) {
       // The tar contains entries that would be written outside of the
       // destination. That doesn't happen by accident, assume that the tar file
       // is malicious.
       await reader.cancel();
-      throw FormatException('Invalid tar entry: ${entry.name}');
+      throw FormatException('Invalid tar entry: `${entry.name}`');
     }
 
     final parentDirectory = p.dirname(filePath);
@@ -1171,7 +1173,10 @@
   final tarContents = Stream.fromIterable(
     contents.map((entry) {
       entry = p.normalize(p.absolute(entry));
-      if (!p.equals(baseDir, entry) && !p.isWithin(baseDir, entry)) {
+      if (p.equals(baseDir, entry)) {
+        return null;
+      }
+      if (!p.isWithin(baseDir, entry)) {
         throw ArgumentError('Entry $entry is not inside $baseDir.');
       }
 
@@ -1189,7 +1194,13 @@
       }
       if (stat.type == FileSystemEntityType.directory) {
         return TarEntry(
-          TarHeader(name: name, typeFlag: TypeFlag.dir),
+          TarHeader(
+            name: name,
+            mode: _defaultMode | _executableMask,
+            typeFlag: TypeFlag.dir,
+            userName: 'pub',
+            groupName: 'pub',
+          ),
           Stream.fromIterable([]),
         );
       } else {
@@ -1207,7 +1218,7 @@
           file.openRead(),
         );
       }
-    }),
+    }).whereNotNull(),
   );
 
   return ByteStream(
diff --git a/test/lish/empy_directories_test.dart b/test/lish/archive_contents_test.dart
similarity index 67%
rename from test/lish/empy_directories_test.dart
rename to test/lish/archive_contents_test.dart
index 0c24f50..40afd12 100644
--- a/test/lish/empy_directories_test.dart
+++ b/test/lish/archive_contents_test.dart
@@ -15,13 +15,29 @@
 import '../test_pub.dart';
 import 'utils.dart';
 
+/// The assumed default file mode on Linux and macOS
+const _defaultMode = 420; // 644₈
+
+/// Mask for executable bits in file modes.
+const _executableMask = 0x49; // 001 001 001
+
 void main() {
-  test('archives and uploads empty directories in package', () async {
+  test(
+      'archives and uploads empty directories in package. Maintains the executable bit',
+      () async {
     await d.validPackage().create();
     await d.dir(appPath, [
+      d.dir('tool', [d.file('tool.sh', 'commands...')]),
       d.dir('lib', [d.dir('empty')]),
     ]).create();
 
+    if (!Platform.isWindows) {
+      Process.runSync(
+        'chmod',
+        ['+x', p.join(d.sandbox, appPath, 'tool', 'tool.sh')],
+      );
+    }
+
     await servePackages();
     await runPub(
       args: ['publish', '--to-archive=archive.tar.gz'],
@@ -32,7 +48,9 @@
 ├── lib
 │   ├── empty
 │   └── test_pkg.dart (<1 KB)
-└── pubspec.yaml (<1 KB)
+├── pubspec.yaml (<1 KB)
+└── tool
+    └── tool.sh (<1 KB)
 '''),
     );
     expect(
@@ -48,10 +66,27 @@
     while (await tarReader.moveNext()) {
       final entry = tarReader.current;
       if (entry.type == TypeFlag.dir) {
+        if (!Platform.isWindows) {
+          expect(entry.header.mode, _defaultMode | _executableMask);
+        }
         dirs.add(entry.name);
+      } else {
+        if (!Platform.isWindows) {
+          if (entry.name.endsWith('tool.sh')) {
+            expect(
+              entry.header.mode
+                  // chmod +x doesn't sets the executable bit for other users on some platforms only.
+                  |
+                  1,
+              _defaultMode | _executableMask,
+            );
+          } else {
+            expect(entry.header.mode, _defaultMode);
+          }
+        }
       }
     }
-    expect(dirs, ['.', 'lib', 'lib/empty']);
+    expect(dirs, ['tool', 'lib', 'lib/empty']);
     await d.credentialsFile(globalServer, 'access-token').create();
     final pub = await startPublish(globalServer);
 
diff --git a/test/lish/publishing_to_and_from_archive_test.dart b/test/lish/publishing_to_and_from_archive_test.dart
index 9e2e6bf..1a6e72a 100644
--- a/test/lish/publishing_to_and_from_archive_test.dart
+++ b/test/lish/publishing_to_and_from_archive_test.dart
@@ -55,4 +55,17 @@
     );
     await pub.shouldExit(SUCCESS);
   });
+
+  test('Can extract self-published archive', () async {
+    await d.validPackage().create();
+
+    await runPub(
+      args: ['lish', '--to-archive', p.join('..', 'archive.tar.gz')],
+      output: contains(
+        'Wrote package archive at ${p.join('..', 'archive.tar.gz')}',
+      ),
+    );
+    expect(File(d.path('archive.tar.gz')).existsSync(), isTrue);
+    await runPub(args: ['cache', 'preload', p.join('..', 'archive.tar.gz')]);
+  });
 }