Skip to content

Commit 8b349a8

Browse files
authored
fix: symlinks handling (#3298)
1 parent cc5e9da commit 8b349a8

File tree

7 files changed

+566
-67
lines changed

7 files changed

+566
-67
lines changed

lib/src/package.dart

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -278,27 +278,62 @@ See $workspacesDocUrl for more information.
278278
return p.join(root, path);
279279
}
280280

281-
return Ignore.listFiles(
281+
/// Throws if [path] is a link that cannot resolve.
282+
///
283+
/// Circular links will fail to resolve at some depth defined by the os.
284+
void verifyLink(String path) {
285+
final link = Link(path);
286+
if (link.existsSync()) {
287+
try {
288+
link.resolveSymbolicLinksSync();
289+
} on FileSystemException catch (e) {
290+
if (!link.existsSync()) {
291+
return;
292+
}
293+
throw DataException(
294+
'Could not resolve symbolic link $path. $e',
295+
);
296+
}
297+
}
298+
}
299+
300+
/// We check each directory that it doesn't symlink-resolve to the
301+
/// symlink-resolution of any parent directory of itself. This avoids
302+
/// cycles.
303+
///
304+
/// Cache the symlink resolutions here.
305+
final symlinkResolvedDirs = <String, String>{};
306+
String resolveDirSymlinks(String path) {
307+
return symlinkResolvedDirs[path] ??=
308+
Directory(path).resolveSymbolicLinksSync();
309+
}
310+
311+
final result = Ignore.listFiles(
282312
beneath: beneath,
283313
listDir: (dir) {
284-
var contents = Directory(resolve(dir)).listSync();
314+
final resolvedDir = p.normalize(resolve(dir));
315+
verifyLink(resolvedDir);
316+
317+
{
318+
final canonicalized = p.canonicalize(resolvedDir);
319+
final symlinkResolvedDir = resolveDirSymlinks(canonicalized);
320+
for (final parent in parentDirs(p.dirname(canonicalized))) {
321+
final symlinkResolvedParent = resolveDirSymlinks(parent);
322+
if (p.equals(symlinkResolvedDir, symlinkResolvedParent)) {
323+
dataError('''
324+
Pub does not support symlink cycles.
325+
326+
$symlinkResolvedDir => ${p.canonicalize(symlinkResolvedParent)}
327+
''');
328+
}
329+
}
330+
}
331+
var contents = Directory(resolvedDir).listSync(followLinks: false);
332+
285333
if (!recursive) {
286334
contents = contents.where((entity) => entity is! Directory).toList();
287335
}
288336
return contents.map((entity) {
289-
if (linkExists(entity.path)) {
290-
final target = Link(entity.path).targetSync();
291-
if (dirExists(entity.path)) {
292-
throw DataException(
293-
'''Pub does not support publishing packages with directory symlinks: `${entity.path}`.''',
294-
);
295-
}
296-
if (!fileExists(entity.path)) {
297-
throw DataException(
298-
'''Pub does not support publishing packages with non-resolving symlink: `${entity.path}` => `$target`.''',
299-
);
300-
}
301-
}
302337
final relative = p.relative(entity.path, from: root);
303338
if (Platform.isWindows) {
304339
return p.posix.joinAll(p.split(relative));
@@ -367,6 +402,10 @@ See $workspacesDocUrl for more information.
367402
isDir: (dir) => dirExists(resolve(dir)),
368403
includeDirs: includeDirs,
369404
).map(resolve).toList();
405+
for (final f in result) {
406+
verifyLink(f);
407+
}
408+
return result;
370409
}
371410

372411
/// Applies [transform] to each package in the workspace and returns a derived

lib/src/validator/gitignore.dart

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,11 @@ class GitignoreValidator extends Validator {
7171
final unignoredByGitignore = Ignore.listFiles(
7272
beneath: beneath,
7373
listDir: (dir) {
74-
final contents = Directory(resolve(dir)).listSync();
75-
return contents
76-
.where((e) => !(linkExists(e.path) && dirExists(e.path)))
77-
.map(
78-
(entity) => p.posix
79-
.joinAll(p.split(p.relative(entity.path, from: root))),
80-
);
74+
final contents = Directory(resolve(dir)).listSync(followLinks: false);
75+
return contents.map(
76+
(entity) =>
77+
p.posix.joinAll(p.split(p.relative(entity.path, from: root))),
78+
);
8179
},
8280
ignoreForDir: (dir) {
8381
final gitIgnore = resolve('$dir/.gitignore');
@@ -86,7 +84,10 @@ class GitignoreValidator extends Validator {
8684
];
8785
return rules.isEmpty ? null : Ignore(rules);
8886
},
89-
isDir: (dir) => dirExists(resolve(dir)),
87+
isDir: (dir) {
88+
final resolved = resolve(dir);
89+
return dirExists(resolved) && !linkExists(resolved);
90+
},
9091
).map((file) {
9192
final relative = p.relative(resolve(file), from: package.dir);
9293
return Platform.isWindows

test/descriptor.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'package:pub/src/sdk/sdk_package_config.dart';
1515
import 'package:test_descriptor/test_descriptor.dart';
1616

1717
import 'descriptor/git.dart';
18+
import 'descriptor/link_descriptor.dart';
1819
import 'descriptor/package_config.dart';
1920
import 'descriptor/tar.dart';
2021
import 'descriptor/yaml.dart';
@@ -403,3 +404,7 @@ Descriptor flutterVersion(String version) {
403404
FileDescriptor sdkPackagesConfig(SdkPackageConfig sdkPackageConfig) {
404405
return YamlDescriptor('sdk_packages.yaml', yaml(sdkPackageConfig.toMap()));
405406
}
407+
408+
Descriptor link(String name, String target, {bool forceDirectory = false}) {
409+
return LinkDescriptor(name, target, forceDirectory: forceDirectory);
410+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
import 'package:path/path.dart' as p;
8+
import 'package:test/test.dart';
9+
10+
import '../descriptor.dart' as d;
11+
12+
/// Describes a symlink.
13+
class LinkDescriptor extends d.Descriptor {
14+
/// On windows symlinks to directories are distinct from symlinks to files.
15+
final bool forceDirectory;
16+
final String target;
17+
LinkDescriptor(super.name, this.target, {this.forceDirectory = false});
18+
19+
@override
20+
Future<void> create([String? parent]) async {
21+
final path = p.join(parent ?? d.sandbox, name);
22+
if (forceDirectory) {
23+
if (Platform.isWindows) {
24+
Process.runSync('cmd', ['/c', 'mklink', '/D', path, target]);
25+
} else {
26+
Link(path).createSync(target);
27+
}
28+
} else {
29+
Link(path).createSync(target);
30+
}
31+
}
32+
33+
@override
34+
String describe() {
35+
return 'symlink at $name targeting $target';
36+
}
37+
38+
@override
39+
Future<void> validate([String? parent]) async {
40+
final link = Link(p.join(parent ?? d.sandbox, name));
41+
try {
42+
final actualTarget = link.targetSync();
43+
expect(
44+
actualTarget,
45+
target,
46+
reason: 'Link doesn\'t point where expected.',
47+
);
48+
} on FileSystemException catch (e) {
49+
fail('Could not read link at $name $e');
50+
}
51+
}
52+
}

test/lish/symlinks_test.dart

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
import 'package:path/path.dart' as p;
8+
import 'package:tar/tar.dart';
9+
import 'package:test/test.dart';
10+
11+
import '../descriptor.dart' as d;
12+
import '../test_pub.dart';
13+
14+
Future<void> main() async {
15+
test('symlink directories are replaced by their targets', () async {
16+
await d.validPackage().create();
17+
await d.dir('a', [d.file('aa', 'aaa')]).create();
18+
await d.file('t', 'ttt').create();
19+
20+
await d.dir(appPath, [
21+
d.dir('b', [d.file('bb', 'bbb'), d.link('l', p.join(d.sandbox, 't'))]),
22+
d.link(
23+
'symlink_to_dir_outside_package',
24+
p.join(d.sandbox, 'a'),
25+
forceDirectory: true,
26+
),
27+
d.link(
28+
'symlink_to_dir_outside_package_relative',
29+
p.join('..', 'a'),
30+
forceDirectory: true,
31+
),
32+
d.link(
33+
'symlink_to_dir_inside_package',
34+
p.join(d.sandbox, appPath, 'b'),
35+
forceDirectory: true,
36+
),
37+
d.link(
38+
'symlink_to_dir_inside_package_relative',
39+
'b',
40+
forceDirectory: true,
41+
),
42+
]).create();
43+
44+
await runPub(args: ['publish', '--to-archive=archive.tar.gz']);
45+
46+
final reader = TarReader(
47+
File(p.join(d.sandbox, appPath, 'archive.tar.gz'))
48+
.openRead()
49+
.transform(GZipCodec().decoder),
50+
);
51+
52+
while (await reader.moveNext()) {
53+
final current = reader.current;
54+
expect(current.type, isNot(TypeFlag.symlink));
55+
}
56+
57+
await runPub(args: ['cache', 'preload', 'archive.tar.gz']);
58+
59+
await d.dir('test_pkg-1.0.0', [
60+
...d.validPackage().contents,
61+
d.dir('symlink_to_dir_outside_package', [
62+
d.file('aa', 'aaa'),
63+
]),
64+
d.dir('symlink_to_dir_outside_package_relative', [
65+
d.file('aa', 'aaa'),
66+
]),
67+
d.dir('b', [d.file('bb', 'bbb')]),
68+
d.dir('symlink_to_dir_inside_package', [
69+
d.file('bb', 'bbb'),
70+
d.file('l', 'ttt'),
71+
]),
72+
d.dir('symlink_to_dir_inside_package_relative', [
73+
d.file('bb', 'bbb'),
74+
d.file('l', 'ttt'),
75+
]),
76+
]).validate(
77+
p.join(d.sandbox, cachePath, 'hosted', 'pub.dev'),
78+
);
79+
});
80+
}

0 commit comments

Comments
 (0)