Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions pkg/pub_package_reader/lib/src/tar_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const _defaultMode = 420; // 644₈
const _executableMask = 0x49; // 001 001 001

/// Abstract interface that once separated process-based tar and package:tar.
class TarArchive {
final class TarArchive {
final String _path;

/// Maps the normalized names to their original value;
Expand Down Expand Up @@ -96,12 +96,17 @@ class TarArchive {
}

/// Creates a new instance by scanning the archive at [path].
///
/// Throws [TarException] if the archive contains invalid entry names, non-normalized
/// paths, duplicate entries, symlinks, entries pointing outside of the archive,
/// entries exceeding limits, or entries with invalid mode bits.
static Future<TarArchive> scan(
String path, {
int? maxFileCount,
int? maxTotalLengthBytes,
}) async {
final names = <String>{};
final normalizedNames = <String>{};
final reader = TarReader(
File(path).openRead().transform(gzip.decoder),
disallowTrailingData: true,
Expand Down Expand Up @@ -142,18 +147,33 @@ class TarArchive {
}

final normalizedName = _normalize(entry.name);
if (p.isAbsolute(normalizedName)) {
if (p.posix.isAbsolute(normalizedName)) {
throw TarException('Tar entry has absolute name: `${entry.name}`.');
}
if (p.split(normalizedName).contains('..')) {
if (p.posix.split(normalizedName).contains('..')) {
throw TarException(
'Tar entry points outside of the archive: `${entry.name}`.',
);
}

if (!names.add(entry.name)) {
// In POSIX tar archives, directory entries conventionally end with a
// trailing slash, which `p.posix.normalize` strips. We accept directory
// entries both with and without a trailing slash, while `normalizedNames`
// prevents collisions between the two.
final expectedName =
entry.type == TypeFlag.dir && entry.name.endsWith('/')
? '$normalizedName/'
: normalizedName;
if (normalizedName == '.' || entry.name != expectedName) {
throw TarException(
'Tar entry name is not normalized: `${entry.name}`.',
);
}

if (!normalizedNames.add(normalizedName)) {
throw TarException('Duplicate tar entry: `${entry.name}`.');
}
names.add(entry.name);
if (entry.header.linkName != null) {
throw TarException('Symlinks not allowed: `${entry.name}`.');
}
Expand All @@ -171,9 +191,9 @@ Map<String, String> _normalizeNames(List<String> names) {
return files;
}

String _normalize(String path) => p.normalize(path).trim();
String _normalize(String path) => p.posix.normalize(path).trim();

class TarException implements Exception {
final class TarException implements Exception {
final String message;

TarException(this.message);
Expand Down
19 changes: 19 additions & 0 deletions pkg/pub_package_reader/test/_tar_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Future<void> writeTarGzFile(
File file, {
Map<String, String>? textFiles,
Map<String, String>? symlinks,
List<String>? directories,
List<TarEntry>? rawEntries,
}) async {
await () async* {
if (textFiles != null) {
Expand All @@ -39,6 +41,23 @@ Future<void> writeTarGzFile(
);
}
}
if (directories != null) {
for (final d in directories) {
yield TarEntry.data(
TarHeader(
name: d,
typeFlag: TypeFlag.dir,
mode: 493, // 755₈
),
Uint8List(0),
);
}
}
if (rawEntries != null) {
for (final entry in rawEntries) {
yield entry;
}
}
}()
.cast<TarEntry>()
.transform(tarWriter)
Expand Down
93 changes: 93 additions & 0 deletions pkg/pub_package_reader/test/file_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ void main() {
'Failed to scan tar archive. (Duplicate tar entry: `README.md`.)',
);
});

test('duplicate directory with and without trailing slash', () async {
await _withTempDir((tempDir) async {
final file = File(p.join(tempDir, 'x.tar.gz'));
await writeTarGzFile(
file,
directories: ['dir', 'dir/'],
textFiles: {'dir/file.txt': 'content'},
);
await expectLater(
TarArchive.scan(file.path),
throwsA(
isA<TarException>().having(
(e) => e.message,
'message',
contains('Duplicate tar entry: `dir/`.'),
),
),
);
});
});
});

group('tar entry test', () {
Expand Down Expand Up @@ -132,6 +153,78 @@ void main() {
});
}
});

test('non-normalized path in the tar entry', () async {
final alternatives = [
'./abc',
'./pubspec.yaml',
'abc/./def',
'abc//def',
'abc/def/',
'abc/../abc/def',
'abc/def ',
' abc/def',
'.',
'./',
];
for (final path in alternatives) {
await _withTempDir((tempDir) async {
final file = File(p.join(tempDir, 'x.tar.gz'));
await writeTarGzFile(file, textFiles: {path: 'content'});
await expectLater(
TarArchive.scan(file.path),
throwsA(
isA<TarException>().having(
(e) => e.message,
'message',
contains('Tar entry name is not normalized: `$path`.'),
),
),
);
});
}
});

test('valid normalized paths and directories', () async {
await _withTempDir((tempDir) async {
final file = File(p.join(tempDir, 'x.tar.gz'));
await writeTarGzFile(
file,
directories: ['dir1/', 'dir2'],
textFiles: {
'pubspec.yaml': 'name: abc',
'lib/foo.dart': 'void main() {}',
'.gitignore': 'build/',
},
);
final archive = await TarArchive.scan(file.path);
expect(
archive.fileNames,
containsAll([
'dir1',
'dir2',
'pubspec.yaml',
'lib/foo.dart',
'.gitignore',
]),
);
});
});

test('non-normalized entry in summarizePackageArchive', () async {
await _withTempDir((tempDir) async {
final file = File(p.join(tempDir, 'x.tar.gz'));
await writeTarGzFile(
file,
textFiles: {'./pubspec.yaml': minimalTextFiles['pubspec.yaml']!},
);
final summary = await summarizePackageArchive(file.path);
expect(
summary.issues.single.message,
'Failed to scan tar archive. (Tar entry name is not normalized: `./pubspec.yaml`.)',
);
});
});
});
}

Expand Down
Loading