Skip to content

Commit bd53c37

Browse files
yangshunclaude
andcommitted
Handle permission errors gracefully, matching Linux tree behavior
Instead of crashing on inaccessible directories (e.g. "System Volume Information" on Windows), show them with [error opening dir] like Linux tree does. Fixes #41. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2586d6 commit bd53c37

6 files changed

Lines changed: 124 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Lists the contents of directories in a tree-like format, similar to the Linux [`
44

55
`tree` is a command that produces a nicely-formatted indented output of directories and files. When a directory argument is given, tree lists all the files and/or directories found in the given directory.
66

7-
Note: Symlinks are not followed.
7+
Note: Symlinks are not followed. Directories that cannot be opened (e.g. due to permission errors) are listed with `[error opening dir]` instead of crashing, matching Linux `tree` behavior.
88

99
## Quickstart
1010

@@ -130,6 +130,7 @@ type TreeJsonEntry =
130130
// size: present when `sizes: true`
131131
// mode: present when `permissions: true`
132132
// time: present when `date: true`
133+
// error: present when the directory could not be opened (e.g. permission denied)
133134
```
134135

135136
```js

‎src/formats/json.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface TreeJsonEntryBase {
66
size?: number;
77
mode?: string;
88
time?: string;
9+
error?: string;
910
}
1011

1112
interface TreeJsonDirectoryEntry extends TreeJsonEntryBase {
@@ -39,6 +40,10 @@ export function formatAsJson(
3940
base.time = `${node.mtime.toLocaleDateString('en-CA')} ${node.mtime.toLocaleTimeString('en-GB')}`;
4041
}
4142

43+
if (node.openError) {
44+
base.error = 'error opening dir';
45+
}
46+
4247
if (node.isDirectory) {
4348
return {
4449
...base,

‎src/formats/text.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,30 @@ describe('formatAsText', () => {
163163
expect(lines[0]).toMatch(/^\[.+\] a\.ts$/);
164164
});
165165

166+
test('openError shows [error opening dir]', () => {
167+
const node = makeDir('root', [
168+
makeDir('forbidden', [], { path: 'root/forbidden', openError: true }),
169+
makeFile('ok.txt', { path: 'root/ok.txt' }),
170+
]);
171+
const lines = formatAsText(node, opts(), '.');
172+
expect(lines).toEqual([
173+
'root',
174+
'├── forbidden [error opening dir]',
175+
'└── ok.txt',
176+
]);
177+
});
178+
179+
test('openError with trailingSlash', () => {
180+
const node = makeDir('root', [
181+
makeDir('forbidden', [], { path: 'root/forbidden', openError: true }),
182+
]);
183+
const lines = formatAsText(node, opts({ trailingSlash: true }), '.');
184+
expect(lines).toEqual([
185+
'root/',
186+
'└── forbidden/ [error opening dir]',
187+
]);
188+
});
189+
166190
test('vertical line for non-last siblings', () => {
167191
const node = makeDir('root', [
168192
makeDir('dir1', [makeFile('file.txt', { path: 'root/dir1/file.txt' })], {

‎src/formats/text.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export function formatAsText(
6363
line.push('/');
6464
}
6565

66+
if (node.openError) {
67+
line.push(' [error opening dir]');
68+
}
69+
6670
const lines = [line.join('')];
6771

6872
// Recurse into children.

‎src/index.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,53 @@ describe('tree error handling', () => {
643643
expect(lines).toHaveLength(1);
644644
expect(lines[0]).toBe('alpha.txt');
645645
});
646+
647+
test('shows [error opening dir] for inaccessible directories', () => {
648+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-perm-'));
649+
const forbidden = path.join(tmpDir, 'forbidden');
650+
fs.mkdirSync(forbidden);
651+
fs.writeFileSync(path.join(tmpDir, 'ok.txt'), 'hello');
652+
// Remove all permissions from the directory.
653+
fs.chmodSync(forbidden, 0o000);
654+
655+
const result = tree(tmpDir);
656+
expect(result).toContain('forbidden [error opening dir]');
657+
expect(result).toContain('ok.txt');
658+
659+
// Restore permissions for cleanup.
660+
fs.chmodSync(forbidden, 0o755);
661+
fs.rmSync(tmpDir, { recursive: true });
662+
});
663+
664+
test('json includes error for inaccessible directories', () => {
665+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-perm-'));
666+
const forbidden = path.join(tmpDir, 'forbidden');
667+
fs.mkdirSync(forbidden);
668+
fs.chmodSync(forbidden, 0o000);
669+
670+
const result = asDir(treeJson(tmpDir));
671+
const entry = result.contents.find((c) => c.name === 'forbidden');
672+
expect(entry).toBeDefined();
673+
expect((entry as any).error).toBe('error opening dir');
674+
675+
fs.chmodSync(forbidden, 0o755);
676+
fs.rmSync(tmpDir, { recursive: true });
677+
});
678+
679+
test('du handles inaccessible directories gracefully', () => {
680+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tree-perm-'));
681+
const forbidden = path.join(tmpDir, 'forbidden');
682+
fs.mkdirSync(forbidden);
683+
fs.writeFileSync(path.join(tmpDir, 'ok.txt'), 'hello');
684+
fs.chmodSync(forbidden, 0o000);
685+
686+
const result = tree(tmpDir, { du: true });
687+
expect(result).toContain('forbidden [error opening dir]');
688+
expect(result).toContain('ok.txt');
689+
690+
fs.chmodSync(forbidden, 0o755);
691+
fs.rmSync(tmpDir, { recursive: true });
692+
});
646693
});
647694

648695
describe('tree with symlinks', () => {

‎src/index.ts‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface TreeNode {
6464
mtime: Date;
6565
size: number;
6666
children: TreeNode[];
67+
openError?: boolean;
6768
}
6869

6970
// ── Build tree ─────────────────────────────────────────────
@@ -98,14 +99,20 @@ function matchesExcludePattern(
9899
function statEntries(
99100
dirPath: string,
100101
entries: string[],
101-
): Map<string, fs.Stats> {
102+
): { stats: Map<string, fs.Stats>; accessible: string[] } {
102103
const stats = new Map<string, fs.Stats>();
104+
const accessible: string[] = [];
103105

104106
for (const entry of entries) {
105-
stats.set(entry, fs.lstatSync(nodePath.join(dirPath, entry)));
107+
try {
108+
stats.set(entry, fs.lstatSync(nodePath.join(dirPath, entry)));
109+
accessible.push(entry);
110+
} catch {
111+
// Skip entries that cannot be stat'd (e.g. permission denied).
112+
}
106113
}
107114

108-
return stats;
115+
return { stats, accessible };
109116
}
110117

111118
/**
@@ -189,19 +196,25 @@ function filterAndOrderContents(
189196
function getDirectoryContents(
190197
dirPath: string,
191198
options: RequiredOptions,
192-
): { entries: string[]; stats: Map<string, fs.Stats> } {
193-
let entries = fs.readdirSync(dirPath);
199+
): { entries: string[]; stats: Map<string, fs.Stats>; openError: boolean } {
200+
let entries: string[];
201+
202+
try {
203+
entries = fs.readdirSync(dirPath);
204+
} catch {
205+
return { entries: [], stats: new Map(), openError: true };
206+
}
194207

195208
// Filter hidden files before stat'ing to avoid unnecessary syscalls.
196209
if (!options.allFiles) {
197210
entries = entries.filter((entry) => !isHiddenFile(entry));
198211
}
199212

200-
const stats = statEntries(dirPath, entries);
201-
const sorted = sortContentsInPlace(entries, stats, options);
213+
const { stats, accessible } = statEntries(dirPath, entries);
214+
const sorted = sortContentsInPlace(accessible, stats, options);
202215
const filtered = filterAndOrderContents(sorted, stats, options);
203216

204-
return { entries: filtered, stats };
217+
return { entries: filtered, stats, openError: false };
205218
}
206219

207220
/**
@@ -212,10 +225,24 @@ function getDirectoryContents(
212225
*/
213226
function computeSizes(dirPath: string, cache: Map<string, number>): number {
214227
let size = 0;
228+
let entries: string[];
229+
230+
try {
231+
entries = fs.readdirSync(dirPath);
232+
} catch {
233+
cache.set(dirPath, 0);
234+
return 0;
235+
}
215236

216-
for (const entry of fs.readdirSync(dirPath)) {
237+
for (const entry of entries) {
217238
const entryPath = nodePath.join(dirPath, entry);
218-
const stat = fs.lstatSync(entryPath);
239+
240+
let stat: fs.Stats;
241+
try {
242+
stat = fs.lstatSync(entryPath);
243+
} catch {
244+
continue;
245+
}
219246

220247
if (stat.isDirectory()) {
221248
size += computeSizes(entryPath, cache);
@@ -267,7 +294,11 @@ function buildTree(
267294
return node;
268295
}
269296

270-
const { entries, stats } = getDirectoryContents(path, options);
297+
const { entries, stats, openError } = getDirectoryContents(path, options);
298+
299+
if (openError) {
300+
node.openError = true;
301+
}
271302

272303
for (const entry of entries) {
273304
const child = buildTree(

0 commit comments

Comments
 (0)