mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Albert Alises <albert.alises@gmail.com> Co-authored-by: Jaakko Husso <jaakko@n8n.io> Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Dimitri Lavrenük <dimitri.lavrenuek@n8n.io> Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
210 lines
5.8 KiB
TypeScript
210 lines
5.8 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
|
|
import type { AffectedResource } from '../types';
|
|
|
|
const MAX_ENTRIES = 10_000;
|
|
const DEFAULT_MAX_DEPTH = 8;
|
|
|
|
export const EXCLUDED_DIRS = new Set([
|
|
'node_modules',
|
|
'.git',
|
|
'dist',
|
|
'build',
|
|
'coverage',
|
|
'__pycache__',
|
|
'.venv',
|
|
'venv',
|
|
'.vscode',
|
|
'.idea',
|
|
'.next',
|
|
'.nuxt',
|
|
'.cache',
|
|
'.turbo',
|
|
'.output',
|
|
'.svelte-kit',
|
|
]);
|
|
|
|
export interface TreeEntry {
|
|
path: string;
|
|
type: 'file' | 'directory';
|
|
sizeBytes?: number;
|
|
}
|
|
|
|
export interface ScanResult {
|
|
rootPath: string;
|
|
tree: TreeEntry[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
/**
|
|
* Scan a directory using breadth-first traversal with a depth limit.
|
|
* Breadth-first ensures broad coverage of top-level structure before
|
|
* descending into deeply nested paths.
|
|
*/
|
|
export async function scanDirectory(
|
|
dirPath: string,
|
|
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
): Promise<ScanResult> {
|
|
const rootName = path.resolve(dirPath);
|
|
const entries: TreeEntry[] = [];
|
|
let truncated = false;
|
|
|
|
// BFS queue: [absolutePath, relativePath, depth]
|
|
const queue: Array<[string, string, number]> = [[dirPath, '', 0]];
|
|
|
|
while (queue.length > 0) {
|
|
if (entries.length >= MAX_ENTRIES) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
const [fullPath, relativePath, depth] = queue.shift()!;
|
|
|
|
let dirEntries;
|
|
try {
|
|
dirEntries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// Sort: directories first, then files, both alphabetical
|
|
const sorted = dirEntries.sort((a, b) => {
|
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
for (const entry of sorted) {
|
|
if (entries.length >= MAX_ENTRIES) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
if (EXCLUDED_DIRS.has(entry.name) && entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.') && !isAllowedDotFile(entry.name)) continue;
|
|
|
|
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
|
|
if (entry.isDirectory()) {
|
|
entries.push({ path: entryRelPath, type: 'directory' });
|
|
if (depth < maxDepth) {
|
|
queue.push([path.join(fullPath, entry.name), entryRelPath, depth + 1]);
|
|
} else {
|
|
truncated = true;
|
|
}
|
|
} else if (entry.isFile()) {
|
|
try {
|
|
const fullEntryPath = path.join(fullPath, entry.name);
|
|
const stat = await fs.stat(fullEntryPath);
|
|
entries.push({ path: entryRelPath, type: 'file', sizeBytes: stat.size });
|
|
} catch {
|
|
entries.push({ path: entryRelPath, type: 'file' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { rootPath: rootName, tree: entries, truncated };
|
|
}
|
|
|
|
function isAllowedDotFile(name: string): boolean {
|
|
const allowed = new Set([
|
|
'.env',
|
|
'.env.example',
|
|
'.eslintrc',
|
|
'.eslintrc.js',
|
|
'.eslintrc.json',
|
|
'.prettierrc',
|
|
'.prettierrc.js',
|
|
'.prettierrc.json',
|
|
'.editorconfig',
|
|
'.gitignore',
|
|
'.dockerignore',
|
|
'.nvmrc',
|
|
'.node-version',
|
|
'.npmrc',
|
|
'.babelrc',
|
|
'.browserslistrc',
|
|
]);
|
|
return allowed.has(name);
|
|
}
|
|
|
|
/**
|
|
* Resolve a path safely within the base directory.
|
|
*
|
|
* Walks each component of the path individually using `fs.realpath` so that
|
|
* symlinks are resolved at every level during the *security check*. This
|
|
* prevents a symlink inside the root from redirecting reads or writes to a
|
|
* location outside the root.
|
|
*
|
|
* For path components that do not yet exist (e.g. the target of a write
|
|
* operation), the remaining components are appended as plain strings once the
|
|
* deepest existing ancestor has been resolved.
|
|
*
|
|
* Dangling symlinks (a symlink whose target does not exist) are followed
|
|
* manually via `fs.lstat` + `fs.readlink` so that they are subject to the
|
|
* same bounds check as regular symlinks.
|
|
*
|
|
* Returns the logical absolute path (without resolving symlinks), so the
|
|
* caller never needs to know that a symlink is involved.
|
|
*/
|
|
export async function resolveSafePath(basePath: string, relativePath: string): Promise<string> {
|
|
const realBase = await fs.realpath(basePath);
|
|
const absolute = path.resolve(basePath, relativePath);
|
|
|
|
// Walk from the filesystem root, resolving each component in turn.
|
|
const root = path.parse(absolute).root;
|
|
const parts = path.relative(root, absolute).split(path.sep).filter(Boolean);
|
|
|
|
let current = root;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const next = path.join(current, parts[i]);
|
|
|
|
try {
|
|
// Happy path: follows all existing symlinks and returns the real path.
|
|
current = await fs.realpath(next);
|
|
} catch (realpathError) {
|
|
if ((realpathError as NodeJS.ErrnoException).code !== 'ENOENT') throw realpathError;
|
|
|
|
// ENOENT can mean the path is absent OR it is a dangling symlink whose
|
|
// target does not exist. Check with lstat (which does not follow symlinks).
|
|
try {
|
|
const lstat = await fs.lstat(next);
|
|
if (lstat.isSymbolicLink()) {
|
|
// Dangling symlink — follow it manually and continue the walk.
|
|
const target = await fs.readlink(next);
|
|
current = path.resolve(current, target);
|
|
continue;
|
|
}
|
|
} catch {
|
|
// lstat also failed — the path truly does not exist.
|
|
}
|
|
|
|
// Path does not exist and is not a symlink; append remaining parts as-is.
|
|
current = path.join(current, ...parts.slice(i));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!current.startsWith(realBase + path.sep) && current !== realBase) {
|
|
throw new Error(`Path "${relativePath}" escapes the base directory`);
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
/**
|
|
* Resolve a path safely within the base directory and return an AffectedResource.
|
|
* Throws if the path escapes the base directory — propagates as a tool failure
|
|
* before any permission prompt is shown.
|
|
*/
|
|
export async function buildFilesystemResource(
|
|
dir: string,
|
|
inputPath: string,
|
|
toolGroup: 'filesystemRead' | 'filesystemWrite',
|
|
description: string,
|
|
): Promise<AffectedResource> {
|
|
const absolutePath = await resolveSafePath(dir, inputPath);
|
|
return { toolGroup, resource: absolutePath, description };
|
|
}
|