Skip to content

Commit 8c5d7cc

Browse files
authored
Symlink and duplicate check in pkg/pub_package_reader (#4763)
1 parent 72dfcf6 commit 8c5d7cc

File tree

8 files changed

+245
-165
lines changed

8 files changed

+245
-165
lines changed

app/lib/package/backend.dart

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import 'package:gcloud/service_scope.dart' as ss;
1313
import 'package:gcloud/storage.dart';
1414
import 'package:logging/logging.dart';
1515
import 'package:meta/meta.dart';
16-
import 'package:pana/pana.dart' show runProc;
17-
import 'package:path/path.dart' as p;
1816
import 'package:pool/pool.dart';
1917
import 'package:pub_package_reader/pub_package_reader.dart';
2018
import 'package:pub_semver/pub_semver.dart';
@@ -677,8 +675,6 @@ class PackageBackend {
677675
if (archive.hasIssues) {
678676
throw PackageRejectedException(archive.issues.first.message);
679677
}
680-
// TODO: move this to pkg/pub_package_reader
681-
await verifyTarGzSymlinks(filename);
682678

683679
final pubspec = Pubspec.fromYaml(archive.pubspecContent);
684680
PackageRejectedException.check(await nameTracker.accept(pubspec.name),
@@ -1133,43 +1129,6 @@ Future _saveTarballToFS(Stream<List<int>> data, String filename) async {
11331129
_logger.info('Finished streaming tarball to FS.');
11341130
}
11351131

1136-
// Throws [PackageRejectedException] if the archive is not readable or if there
1137-
// is any symlink in the archive.
1138-
@visibleForTesting
1139-
Future<void> verifyTarGzSymlinks(String filename) async {
1140-
Future<List<String>> listFiles(bool verbose) async {
1141-
final pr = await runProc(
1142-
['tar', verbose ? '-tvf' : '-tf', filename],
1143-
);
1144-
if (pr.exitCode != 0) {
1145-
_logger.info('Rejecting package: tar returned with ${pr.exitCode}\n'
1146-
'${pr.stdout}\n${pr.stderr}');
1147-
throw PackageRejectedException.invalidTarGz();
1148-
}
1149-
return pr.stdout.toString().split('\n');
1150-
}
1151-
1152-
final fileNames = (await listFiles(false)).toSet();
1153-
final verboseLines = await listFiles(true);
1154-
// Check if the file has any symlink.
1155-
for (final line in verboseLines) {
1156-
if (line.startsWith('l')) {
1157-
// report only the source path
1158-
// if output is non-standard for any reason, this reports the full line
1159-
final parts = line.split(' -> ');
1160-
final source = parts.first.split(' ').last;
1161-
final target = parts.last;
1162-
if (p.isAbsolute(target)) {
1163-
throw PackageRejectedException.brokenSymlink(source, target);
1164-
}
1165-
final resolvedPath = p.normalize(Uri(path: source).resolve(target).path);
1166-
if (!fileNames.contains(resolvedPath)) {
1167-
throw PackageRejectedException.brokenSymlink(source, target);
1168-
}
1169-
}
1170-
}
1171-
}
1172-
11731132
class _UploadEntities {
11741133
final PackageVersion packageVersion;
11751134
final PackageVersionInfo packageVersionInfo;

app/test/package/upload_test.dart

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'dart:async';
6-
import 'dart:io';
76

87
import 'package:gcloud/db.dart';
9-
import 'package:pana/pana.dart';
10-
import 'package:path/path.dart' as p;
118
import 'package:test/test.dart';
129
import 'package:yaml/yaml.dart';
1310

@@ -507,91 +504,4 @@ void main() {
507504
timeout: Timeout.factor(1.5),
508505
);
509506
});
510-
511-
group('.tar.gz verification', () {
512-
test('invalid file content', () async {
513-
await withTempDirectory((tempDir) async {
514-
final file = File(p.join(tempDir, 'bad.tar.gz'));
515-
await file.writeAsBytes([0, 1, 2]);
516-
final rs = verifyTarGzSymlinks(file.path);
517-
await expectLater(
518-
rs,
519-
throwsA(isA<PackageRejectedException>().having(
520-
(e) => '$e',
521-
'text',
522-
contains('is not a valid'),
523-
)));
524-
});
525-
});
526-
527-
Future<void> runArchiveVerification({
528-
Future<void> Function(String pkgDirPath) setupDir,
529-
String expectedErrorMessage,
530-
}) async {
531-
return await withTempDirectory((tempDir) async {
532-
final archiveFile = File(p.join(tempDir, 'with-symlink.tar.gz'));
533-
final pkgDir = Directory(p.join(tempDir, 'pkg'));
534-
await pkgDir.create(recursive: true);
535-
await setupDir(pkgDir.path);
536-
final pr = await runProc(['tar', '-czvf', archiveFile.path, 'pkg'],
537-
workingDirectory: tempDir);
538-
expect(pr.exitCode, 0);
539-
final rs = verifyTarGzSymlinks(archiveFile.path);
540-
if (expectedErrorMessage != null) {
541-
await expectLater(
542-
rs,
543-
throwsA(isA<PackageRejectedException>().having(
544-
(e) => '$e',
545-
'text',
546-
contains(expectedErrorMessage),
547-
)));
548-
} else {
549-
await rs;
550-
}
551-
});
552-
}
553-
554-
test('has a relative symlink outside of archive', () async {
555-
await runArchiveVerification(
556-
setupDir: (pkgDir) async {
557-
await Link(p.join(pkgDir, 'README.md')).create('../../README.md');
558-
},
559-
expectedErrorMessage:
560-
'Package archive contains a broken symlink: `pkg/README.md` -> `../../README.md`.',
561-
);
562-
});
563-
564-
test('has an absolute symlink', () async {
565-
await runArchiveVerification(
566-
setupDir: (pkgDir) async {
567-
await Link(p.join(pkgDir, 'README.md')).create('/README.md');
568-
},
569-
expectedErrorMessage:
570-
'Package archive contains a broken symlink: `pkg/README.md` -> `/README.md`.',
571-
);
572-
});
573-
574-
// This test-case should fail, but it for simplicity it is accepted by pub.
575-
// TODO: fix verification to detect circular links
576-
test('has symlink(s) with circular links', () async {
577-
// TODO: this use case should fail instead
578-
await runArchiveVerification(
579-
setupDir: (pkgDir) async {
580-
await Link(p.join(pkgDir, 'README.md')).create('./README.md');
581-
},
582-
);
583-
});
584-
585-
test('valid symlink to another file', () async {
586-
await runArchiveVerification(
587-
setupDir: (pkgDir) async {
588-
await Directory(p.join(pkgDir, 'a')).create();
589-
await Directory(p.join(pkgDir, 'b')).create();
590-
await File(p.join(pkgDir, 'a', 'existing.txt')).writeAsString('');
591-
await Link(p.join(pkgDir, 'b', 'link.txt'))
592-
.create('../a/existing.txt');
593-
},
594-
);
595-
});
596-
});
597507
}

pkg/pub_package_reader/lib/pub_package_reader.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ Future<PackageSummary> summarizePackageArchive(
9393
ArchiveIssue('Failed to scan tar archive. ($e)'));
9494
}
9595

96+
// symlinks check
97+
final brokenSymlinks = tar.brokenSymlinks();
98+
if (brokenSymlinks.isNotEmpty) {
99+
final from = brokenSymlinks.keys.first;
100+
final to = brokenSymlinks[from];
101+
return PackageSummary.fail(ArchiveIssue(
102+
'Package archive contains a broken symlink: `$from` -> `$to`.'));
103+
}
104+
96105
// processing pubspec.yaml
97106
final pubspecPath = tar.searchForFile(['pubspec.yaml']);
98107
if (pubspecPath == null) {

pkg/pub_package_reader/lib/src/tar_utils.dart

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,32 @@ abstract class TarArchive {
1919
/// Maps the normalized names to their original value;
2020
final Map<String, String> _normalizedNames;
2121

22-
TarArchive._(this._normalizedNames)
23-
: fileNames = _normalizedNames.keys.toList()..sort();
24-
25-
TarArchive(List<String> names) : this._(_normalizeNames(names));
22+
/// The map of files that are symlinks, using normalized names
23+
/// in both parts of the map entries.
24+
final Map<String, String> _symlinks;
25+
26+
TarArchive._(
27+
this._normalizedNames,
28+
this._symlinks,
29+
) : fileNames = _normalizedNames.keys.toList()..sort();
30+
31+
TarArchive(List<String> names, Map<String, String> symlinks)
32+
: this._(
33+
_normalizeNames(names),
34+
symlinks.map((key, value) =>
35+
MapEntry<String, String>(_normalize(key), _normalize(value))),
36+
);
2637

2738
static Map<String, String> _normalizeNames(List<String> names) {
2839
final files = <String, String>{};
2940
for (final name in names) {
30-
final normalized = p.normalize(name).trim();
31-
files[normalized] = name;
41+
files[_normalize(name)] = name;
3242
}
3343
return files;
3444
}
3545

46+
static String _normalize(String path) => p.normalize(path).trim();
47+
3648
/// Reads file content as String.
3749
Future<String> readContentAsString(String name, {int maxLength = 0});
3850

@@ -52,6 +64,24 @@ abstract class TarArchive {
5264
return null;
5365
}
5466

67+
/// Returns the brokens links (that point outside, or to a non-existent file).
68+
Map<String, String> brokenSymlinks() {
69+
final broken = <String, String>{};
70+
for (final from in _symlinks.keys) {
71+
final to = _symlinks[from];
72+
final toAsUri = Uri.tryParse(to);
73+
if (toAsUri == null || toAsUri.isAbsolute) {
74+
broken[from] = to;
75+
continue;
76+
}
77+
final resolvedPath = p.normalize(Uri(path: from).resolve(to).path);
78+
if (!fileNames.contains(resolvedPath)) {
79+
broken[from] = to;
80+
}
81+
}
82+
return broken;
83+
}
84+
5585
/// Creates a new instance by scanning the archive at [path].
5686
static Future<TarArchive> scan(String path, {bool useNative = false}) async {
5787
return useNative
@@ -64,22 +94,49 @@ abstract class TarArchive {
6494
class _ProcessTarArchive extends TarArchive {
6595
final String _path;
6696

67-
_ProcessTarArchive._(this._path, List<String> names) : super(names);
97+
_ProcessTarArchive._(
98+
this._path,
99+
List<String> names,
100+
Map<String, String> symlinks,
101+
) : super(names, symlinks);
68102

69103
/// Creates a new instance by scanning the archive at [path].
70104
static Future<_ProcessTarArchive> _scan(String path) async {
71-
final args = ['-tzf', path];
72-
final result = await Process.run('tar', args);
73-
if (result.exitCode != 0) {
105+
// normal file list
106+
final rs1 = await _runTar(['-tzf', path]);
107+
final names = <String>{};
108+
for (final name in (rs1.stdout as String).split('\n')) {
109+
if (!names.add(name)) {
110+
throw Exception('Duplicate tar entry: `$name`.');
111+
}
112+
}
113+
114+
// symlink file list
115+
final symlinks = <String, String>{};
116+
final rs2 = await _runTar(['-tvzf', path]);
117+
for (final line in (rs2.stdout as String).split('\n')) {
118+
if (line.startsWith('l')) {
119+
final parts = line.split(' -> ');
120+
if (parts.length != 2) {
121+
throw Exception('Unable to parse symlinks: $line');
122+
}
123+
symlinks[parts.first.trim().split(' ').last] = parts.last.trim();
124+
}
125+
}
126+
127+
return _ProcessTarArchive._(path, names.toList(), symlinks);
128+
}
129+
130+
static Future<ProcessResult> _runTar(List<String> args) async {
131+
final rs = await Process.run('tar', args);
132+
if (rs.exitCode != 0) {
74133
_logger.warning('The "tar $args" command failed:\n'
75-
'with exit code: ${result.exitCode}\n'
76-
'stdout: ${result.stdout}\n'
77-
'stderr: ${result.stderr}');
134+
'with exit code: ${rs.exitCode}\n'
135+
'stdout: ${rs.stdout}\n'
136+
'stderr: ${rs.stderr}');
78137
throw Exception('Failed to list tarball contents.');
79138
}
80-
81-
final names = (result.stdout as String).split('\n').toList();
82-
return _ProcessTarArchive._(path, names);
139+
return rs;
83140
}
84141

85142
/// Reads a text content of [name] from the tar.gz file identified by [_path].
@@ -107,21 +164,31 @@ class _ProcessTarArchive extends TarArchive {
107164

108165
class _PkgTarArchive extends TarArchive {
109166
final String _path;
110-
_PkgTarArchive._(this._path, List<String> names) : super(names);
167+
_PkgTarArchive._(
168+
this._path,
169+
List<String> names,
170+
Map<String, String> symlinks,
171+
) : super(names, symlinks);
111172

112173
/// Creates a new instance by scanning the archive at [path].
113174
static Future<_PkgTarArchive> _scan(String path) async {
114-
final names = <String>[];
175+
final names = <String>{};
176+
final symlinks = <String, String>{};
115177
final reader = TarReader(
116178
File(path).openRead().transform(gzip.decoder),
117179
disallowTrailingData: true,
118180
);
119181
while (await reader.moveNext()) {
120182
final entry = reader.current;
121-
names.add(entry.name);
183+
if (!names.add(entry.name)) {
184+
throw Exception('Duplicate tar entry: `${entry.name}`.');
185+
}
186+
if (entry.header.linkName != null) {
187+
symlinks[entry.name] = entry.header.linkName;
188+
}
122189
}
123190
await reader.cancel();
124-
return _PkgTarArchive._(path, names);
191+
return _PkgTarArchive._(path, names.toList(), symlinks);
125192
}
126193

127194
@override

pkg/pub_package_reader/pubspec.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ packages:
4949
name: checked_yaml
5050
url: "https://pub.dartlang.org"
5151
source: hosted
52-
version: "1.0.2"
52+
version: "1.0.4"
5353
chunked_stream:
5454
dependency: transitive
5555
description:
@@ -224,7 +224,7 @@ packages:
224224
name: pub_semver
225225
url: "https://pub.dartlang.org"
226226
source: hosted
227-
version: "1.4.4"
227+
version: "2.0.0"
228228
pubspec_parse:
229229
dependency: "direct main"
230230
description:
@@ -378,6 +378,6 @@ packages:
378378
name: yaml
379379
url: "https://pub.dartlang.org"
380380
source: hosted
381-
version: "2.2.1"
381+
version: "3.1.0"
382382
sdks:
383383
dart: ">=2.12.0 <3.0.0"

0 commit comments

Comments
 (0)