Skip to content

Commit 7b0c22d

Browse files
fs: bubble errors, improving UX
1 parent 85df769 commit 7b0c22d

File tree

3 files changed

+141
-27
lines changed

3 files changed

+141
-27
lines changed

lib/internal/fs/glob.js

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,14 @@ class Pattern {
182182
indexes;
183183
symlinks;
184184
last;
185+
visited;
185186

186-
constructor(pattern, globStrings, indexes, symlinks) {
187+
constructor(pattern, globStrings, indexes, symlinks, visited = null) {
187188
this.#pattern = pattern;
188189
this.#globStrings = globStrings;
189190
this.indexes = indexes;
190191
this.symlinks = symlinks;
192+
this.visited = visited;
191193
this.last = pattern.length - 1;
192194
}
193195

@@ -205,8 +207,8 @@ class Pattern {
205207
at(index) {
206208
return ArrayPrototypeAt(this.#pattern, index);
207209
}
208-
child(indexes, symlinks = new SafeSet()) {
209-
return new Pattern(this.#pattern, this.#globStrings, indexes, symlinks);
210+
child(indexes, symlinks = new SafeSet(), visited = null) {
211+
return new Pattern(this.#pattern, this.#globStrings, indexes, symlinks, visited ?? this.visited);
210212
}
211213
test(index, path) {
212214
if (index > this.#pattern.length) {
@@ -432,12 +434,24 @@ class Glob {
432434
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
433435

434436
let isDirectory = entry.isDirectory();
437+
let symlinkStat = null;
435438
if (entry.isSymbolicLink() && this.#followSymlinks) {
436-
try {
437-
const stat = fsStatSync(join(fullpath, entry.name));
438-
isDirectory = stat.isDirectory();
439-
} catch {
440-
// ignore
439+
const stat = fsStatSync(join(fullpath, entry.name));
440+
isDirectory = stat.isDirectory();
441+
if (isDirectory) {
442+
// Check for cycles by looking at visited inodes
443+
let node = pattern.visited;
444+
while (node) {
445+
if (node.ino === stat.ino && node.dev === stat.dev) {
446+
// Cycle detected, skip this symlink
447+
isDirectory = false;
448+
break;
449+
}
450+
node = node.parent;
451+
}
452+
if (isDirectory) {
453+
symlinkStat = stat;
454+
}
441455
}
442456
}
443457

@@ -548,7 +562,12 @@ class Glob {
548562
}
549563
if (subPatterns.size > 0) {
550564
// If there are potential patterns, add to queue
551-
this.#addSubpattern(entryPath, pattern.child(subPatterns, nSymlinks));
565+
let newVisited = pattern.visited;
566+
if (symlinkStat) {
567+
// Add this symlink target to the visited chain
568+
newVisited = { __proto__: null, ino: symlinkStat.ino, dev: symlinkStat.dev, parent: pattern.visited };
569+
}
570+
this.#addSubpattern(entryPath, pattern.child(subPatterns, nSymlinks, newVisited));
552571
}
553572
}
554573
}
@@ -652,12 +671,24 @@ class Glob {
652671
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
653672

654673
let isDirectory = entry.isDirectory();
674+
let symlinkStat = null;
655675
if (entry.isSymbolicLink() && this.#followSymlinks) {
656-
try {
657-
const s = await fsStat(join(fullpath, entry.name));
658-
isDirectory = s.isDirectory();
659-
} catch {
660-
// ignore
676+
const s = await fsStat(join(fullpath, entry.name));
677+
isDirectory = s.isDirectory();
678+
if (isDirectory) {
679+
// Check for cycles by looking at visited inodes
680+
let node = pattern.visited;
681+
while (node) {
682+
if (node.ino === s.ino && node.dev === s.dev) {
683+
// Cycle detected, skip this symlink
684+
isDirectory = false;
685+
break;
686+
}
687+
node = node.parent;
688+
}
689+
if (isDirectory) {
690+
symlinkStat = s;
691+
}
661692
}
662693
}
663694

@@ -788,7 +819,11 @@ class Glob {
788819
}
789820
if (subPatterns.size > 0) {
790821
// If there are potential patterns, add to queue
791-
this.#addSubpattern(entryPath, pattern.child(subPatterns, nSymlinks));
822+
let newVisited = pattern.visited;
823+
if (symlinkStat) {
824+
newVisited = { __proto__: null, ino: symlinkStat.ino, dev: symlinkStat.dev, parent: pattern.visited };
825+
}
826+
this.#addSubpattern(entryPath, pattern.child(subPatterns, nSymlinks, newVisited));
792827
}
793828
}
794829
}

test-stat-identity.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// test-stat-identity.mjs
2+
import { statSync, rmSync } from 'fs';
3+
import { mkdirSync, symlinkSync } from 'fs';
4+
import { join, resolve } from 'path';
5+
6+
const tmp = resolve('tmp_stat_test');
7+
try {
8+
rmSync(tmp, { recursive: true, force: true });
9+
} catch {}
10+
try {
11+
mkdirSync(tmp);
12+
} catch {}
13+
14+
const dir = join(tmp, 'real_dir');
15+
try {
16+
mkdirSync(dir);
17+
} catch {}
18+
19+
const link1 = join(tmp, 'link1');
20+
// Link points to 'real_dir' which is in the same folder as 'link1'
21+
try {
22+
symlinkSync('real_dir', link1);
23+
} catch {}
24+
25+
try {
26+
const s1 = statSync(dir);
27+
const s2 = statSync(link1);
28+
29+
console.log(`Real: dev=${s1.dev}, ino=${s1.ino}`);
30+
console.log(`Link: dev=${s2.dev}, ino=${s2.ino}`);
31+
console.log(`Match? ${s1.dev === s2.dev && s1.ino === s2.ino}`);
32+
} catch (e) {
33+
console.error(e);
34+
}

test/parallel/test-fs-glob.mjs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as common from '../common/index.mjs';
22
import tmpdir from '../common/tmpdir.js';
33
import { resolve, dirname, sep, relative, join, isAbsolute } from 'node:path';
44
import { mkdir, writeFile, symlink, glob as asyncGlob } from 'node:fs/promises';
5-
import { glob, globSync, Dirent, chmodSync } from 'node:fs';
5+
import { glob, globSync, Dirent, chmodSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
66
import { test, describe } from 'node:test';
77
import { pathToFileURL } from 'node:url';
88
import { promisify } from 'node:util';
@@ -544,21 +544,66 @@ describe('glob - with restricted directory', function() {
544544
});
545545
});
546546

547-
describe('glob follow', () => {
548-
test('should return matched files in symlinked directory when follow is true', async () => {
547+
describe('glob followSymlinks', () => {
548+
test('should not throw ELOOP with followSymlinks on symlink loop (async)', async () => {
549549
if (common.isWindows) return;
550-
const relFilesPromise = asyncGlob('**', { cwd: fixtureDir, followSymlinks: true });
551-
let count = 0;
552-
// eslint-disable-next-line no-unused-vars
553-
for await (const file of relFilesPromise) {
554-
count++;
550+
const files = [];
551+
for await (const file of asyncGlob('**', { cwd: fixtureDir, followSymlinks: true })) {
552+
files.push(file);
555553
}
556-
assert.ok(count > 0);
554+
// Should have results but no infinite loop
555+
assert.ok(files.length > 0);
556+
assert.ok(files.some((f) => f === 'a/symlink/a/b/c'));
557+
assert.ok(files.some((f) => f.startsWith('a/symlink/a/b/c/a/')));
558+
const depth = Math.max(...files.filter((f) => f.startsWith('a/symlink/')).map((f) => f.split('/').length));
559+
assert.ok(depth < 20);
557560
});
558561

559-
test('should return matched files in symlinked directory when follow is true (sync)', () => {
562+
test('should not throw ELOOP with followSymlinks on symlink loop (sync)', () => {
560563
if (common.isWindows) return;
561-
const relFiles = globSync('**', { cwd: fixtureDir, followSymlinks: true });
562-
assert.ok(relFiles.length > 0);
564+
const files = globSync('**', { cwd: fixtureDir, followSymlinks: true });
565+
assert.ok(files.length > 0);
566+
assert.ok(files.some((f) => f === 'a/symlink/a/b/c'));
567+
assert.ok(files.some((f) => f.startsWith('a/symlink/a/b/c/a/')));
568+
const depth = Math.max(...files.filter((f) => f.startsWith('a/symlink/')).map((f) => f.split('/').length));
569+
assert.ok(depth < 20);
570+
});
571+
572+
test('should handle symlinks without cycles (async)', async () => {
573+
if (common.isWindows) return;
574+
const deepLinkDir = tmpdir.resolve('deep-links');
575+
await mkdir(deepLinkDir, { recursive: true });
576+
577+
await mkdir(join(deepLinkDir, 'level1'), { recursive: true });
578+
await mkdir(join(deepLinkDir, 'level1', 'level2'), { recursive: true });
579+
await writeFile(join(deepLinkDir, 'level1', 'level2', 'file.txt'), 'deep');
580+
await symlink('level1/level2', join(deepLinkDir, 'link-to-level2'));
581+
582+
const files = [];
583+
for await (const file of asyncGlob('**/*.txt', { cwd: deepLinkDir, followSymlinks: true })) {
584+
files.push(file);
585+
}
586+
587+
assert.ok(files.some((f) => f.includes('level1/level2/file.txt')));
588+
assert.ok(files.some((f) => f.includes('link-to-level2/file.txt')));
589+
});
590+
591+
test('should handle symlinks without cycles (sync)', () => {
592+
if (common.isWindows) return;
593+
const deepLinkDir = tmpdir.resolve('deep-links-sync');
594+
try {
595+
mkdirSync(deepLinkDir, { recursive: true });
596+
mkdirSync(join(deepLinkDir, 'level1'), { recursive: true });
597+
mkdirSync(join(deepLinkDir, 'level1', 'level2'), { recursive: true });
598+
writeFileSync(join(deepLinkDir, 'level1', 'level2', 'file.txt'), 'deep');
599+
symlinkSync('level1/level2', join(deepLinkDir, 'link-to-level2'));
600+
601+
const files = globSync('**/*.txt', { cwd: deepLinkDir, followSymlinks: true });
602+
603+
assert.ok(files.some((f) => f.includes('level1/level2/file.txt')));
604+
assert.ok(files.some((f) => f.includes('link-to-level2/file.txt')));
605+
} catch {
606+
// Cleanup errors are ok
607+
}
563608
});
564609
});

0 commit comments

Comments
 (0)