Files
Maestro/src/__tests__/main/autorun-folder-validation.test.ts
chr1syy 565636674e fix: Windows PATH issues and SSH remote execution improvements
- Fix Windows agent execution by using buildExpandedEnv for proper PATH expansion
- Add PowerShell support for SSH commands to handle long command lines (32K+ chars)
- Implement here document support for large OpenCode prompts over SSH
- Add raw stdin prompt sending for agents without stream-json support
- Restrict inline wizard to Claude, Claude Code, and Codex (OpenCode incompatible)
- Improve argument escaping for both cmd.exe and PowerShell shells
- Update tsconfig.main.json to include shared files for proper compilation
- Enhance agent path resolution for packaged Electron applications
- Add read-only mode for OpenCode in wizard conversations
- Update tests and UI components for better SSH remote configuration
2026-01-31 18:06:23 -05:00

1134 lines
37 KiB
TypeScript

/**
* Tests for Auto Run folder validation, path security, and file filtering
*
* Tests cover:
* - Folder path validation and security checks
* - Subfolder scanning (recursive directory traversal)
* - Hidden file filtering (files starting with .)
* - Symlink handling and security implications
*
* Task 4.3 from Testing-II.md
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'path';
// Mock electron modules
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
},
BrowserWindow: vi.fn(),
app: {
on: vi.fn(),
},
}));
// Mock logger
vi.mock('../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock fs modules
const mockReaddir = vi.fn();
const mockStat = vi.fn();
const mockLstat = vi.fn();
const mockRealpath = vi.fn();
const mockAccess = vi.fn();
vi.mock('fs/promises', () => ({
default: {
readdir: mockReaddir,
stat: mockStat,
lstat: mockLstat,
realpath: mockRealpath,
access: mockAccess,
},
readdir: mockReaddir,
stat: mockStat,
lstat: mockLstat,
realpath: mockRealpath,
access: mockAccess,
}));
vi.mock('fs', () => ({
default: {
watch: vi.fn(),
},
watch: vi.fn(),
}));
// Helper to create mock directory entries
interface MockDirent {
name: string;
isDirectory: () => boolean;
isFile: () => boolean;
isBlockDevice: () => boolean;
isCharacterDevice: () => boolean;
isFIFO: () => boolean;
isSocket: () => boolean;
isSymbolicLink: () => boolean;
}
function createDirent(name: string, type: 'file' | 'directory' | 'symlink'): MockDirent {
return {
name,
isDirectory: () => type === 'directory',
isFile: () => type === 'file',
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
isSymbolicLink: () => type === 'symlink',
};
}
// Implementation of validatePathWithinFolder from autorun.ts
function validatePathWithinFolder(filePath: string, folderPath: string): boolean {
const resolvedPath = path.resolve(filePath);
const resolvedFolder = path.resolve(folderPath);
return resolvedPath.startsWith(resolvedFolder + path.sep) || resolvedPath === resolvedFolder;
}
// Implementation of hidden file filter from scanDirectory
function isHiddenFile(name: string): boolean {
return name.startsWith('.');
}
// Tree node interface matching autorun.ts
interface TreeNode {
name: string;
type: 'file' | 'folder';
path: string;
children?: TreeNode[];
}
// Recursive scanDirectory implementation for testing
async function scanDirectory(
dirPath: string,
relativePath: string = '',
mockReaddirFn: typeof mockReaddir
): Promise<TreeNode[]> {
const entries = await mockReaddirFn(dirPath, { withFileTypes: true });
const nodes: TreeNode[] = [];
// Sort entries: folders first, then files, both alphabetically
const sortedEntries = entries
.filter((entry: MockDirent) => !entry.name.startsWith('.'))
.sort((a: MockDirent, b: MockDirent) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
for (const entry of sortedEntries) {
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
// Recursively scan subdirectory
const children = await scanDirectory(
path.join(dirPath, entry.name),
entryRelativePath,
mockReaddirFn
);
// Only include folders that contain .md files
if (children.length > 0) {
nodes.push({
name: entry.name,
type: 'folder',
path: entryRelativePath,
children,
});
}
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
nodes.push({
name: entry.name.slice(0, -3),
type: 'file',
path: entryRelativePath.slice(0, -3),
});
}
}
return nodes;
}
describe('Auto Run Folder Validation', () => {
beforeEach(() => {
vi.clearAllMocks();
mockReaddir.mockReset();
mockStat.mockReset();
mockLstat.mockReset();
mockRealpath.mockReset();
mockAccess.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Folder Path Validation', () => {
describe('validatePathWithinFolder', () => {
it('should accept files directly within the folder', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/document.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should accept files in nested subfolders', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/Phase1/Task1.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should accept deeply nested files', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/a/b/c/d/e/deep.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should accept the folder path itself', () => {
const folderPath = '/test/autorun';
expect(validatePathWithinFolder(folderPath, folderPath)).toBe(true);
});
it('should reject files outside the folder', () => {
const folderPath = '/test/autorun';
const filePath = '/etc/passwd';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should reject sibling folder paths', () => {
const folderPath = '/test/autorun';
const filePath = '/test/other-folder/document.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should reject parent folder paths', () => {
const folderPath = '/test/autorun';
const filePath = '/test/document.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should prevent prefix attack with similar folder names', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun-evil/malicious.md';
// This is the critical security check - "autorun-evil" should NOT match "autorun"
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should prevent prefix attack with underscore suffix', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun_backup/secret.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should handle Windows-style paths', () => {
// Note: path.resolve behavior is platform-dependent
const folderPath = '/Users/test/autorun';
const validPath = '/Users/test/autorun/doc.md';
const invalidPath = '/Users/test/autorun2/doc.md';
expect(validatePathWithinFolder(validPath, folderPath)).toBe(true);
expect(validatePathWithinFolder(invalidPath, folderPath)).toBe(false);
});
it('should handle trailing slashes correctly', () => {
const folderPath = '/test/autorun';
const folderPathWithSlash = '/test/autorun/';
const filePath = '/test/autorun/document.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
// path.resolve normalizes trailing slashes
expect(validatePathWithinFolder(filePath, folderPathWithSlash)).toBe(true);
});
it('should handle paths with spaces', () => {
const folderPath = '/test/Auto Run Docs';
const filePath = '/test/Auto Run Docs/My Document.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should handle unicode paths', () => {
const folderPath = '/test/文档目录';
const filePath = '/test/文档目录/任务.md';
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
});
describe('Directory Traversal Prevention', () => {
it('should detect simple .. traversal', () => {
const filename = '../../../etc/passwd';
const hasTraversal = filename.includes('..');
expect(hasTraversal).toBe(true);
});
it('should detect encoded traversal attempts', () => {
const encodedTraversals = ['..%2F..%2Fetc%2Fpasswd', '..%252F..%252Fetc', '%2e%2e%2f'];
// While URL-encoded, the raw check still works on decoded paths
const decodedPath = decodeURIComponent('..%2F..%2Fetc');
expect(decodedPath.includes('..')).toBe(true);
});
it('should detect Windows-style traversal', () => {
const windowsTraversals = ['..\\..\\Windows\\System32', '..%5C..%5CWindows'];
windowsTraversals.forEach((traversal) => {
expect(traversal.includes('..') || decodeURIComponent(traversal).includes('..')).toBe(
true
);
});
});
it('should detect null byte injection attempts', () => {
const nullByteAttempts = ['doc.md\0.exe', 'doc\0../secret'];
nullByteAttempts.forEach((attempt) => {
expect(attempt.includes('\0')).toBe(true);
});
});
it('should validate resolved path prevents symlink escape', () => {
// Even if a symlink points outside, path.resolve follows it
// The handler uses resolved paths to catch this
const folderPath = '/test/autorun';
// Simulated scenario: /test/autorun/link -> /etc
// If we follow the symlink, the resolved path would be /etc/passwd
const resolvedSymlinkTarget = '/etc/passwd';
expect(validatePathWithinFolder(resolvedSymlinkTarget, folderPath)).toBe(false);
});
});
describe('Path Normalization', () => {
it('should normalize paths with redundant separators', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun//document.md';
const resolved = path.resolve(filePath);
// Normalize expected path to use platform separators
const expectedPath = path.resolve('/test/autorun/document.md');
expect(resolved).toBe(expectedPath);
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should normalize paths with . components', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/./document.md';
const resolved = path.resolve(filePath);
// Normalize expected path to use platform separators
const expectedPath = path.resolve('/test/autorun/document.md');
expect(resolved).toBe(expectedPath);
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should handle mixed . and .. in allowed paths', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/subfolder/../document.md';
const resolved = path.resolve(filePath);
// Normalize expected path to use platform separators
const expectedPath = path.resolve('/test/autorun/document.md');
expect(resolved).toBe(expectedPath);
expect(validatePathWithinFolder(filePath, folderPath)).toBe(true);
});
it('should reject .. that escapes the folder', () => {
const folderPath = '/test/autorun';
const filePath = '/test/autorun/../secret.md';
const resolved = path.resolve(filePath);
// Normalize expected path to use platform separators
const expectedPath = path.resolve('/test/secret.md');
expect(resolved).toBe(expectedPath);
expect(validatePathWithinFolder(filePath, folderPath)).toBe(false);
});
it('should handle path.join with potentially malicious input', () => {
const folderPath = '/test/autorun';
// path.join is dangerous with absolute paths in the second argument
const maliciousInput = process.platform === 'win32' ? 'C:\\etc\\passwd' : '/etc/passwd';
const joined = path.join(folderPath, maliciousInput);
// On Unix, path.join treats absolute second arg as root
// On Windows, path.join concatenates since the second arg is not considered absolute in the same way
if (process.platform === 'win32') {
// On Windows, the result is concatenated
expect(joined).toBe('\\test\\autorun\\C:\\etc\\passwd');
expect(validatePathWithinFolder(joined, folderPath)).toBe(true); // It's still within the folder
} else {
// On Unix, path.join with absolute second arg gives the absolute path
expect(joined).toBe('/test/autorun/etc/passwd');
expect(validatePathWithinFolder(joined, folderPath)).toBe(false);
}
// But if someone bypasses join and uses the raw path:
expect(validatePathWithinFolder(maliciousInput, folderPath)).toBe(false);
});
});
});
describe('Subfolder Scanning (Recursive)', () => {
describe('Basic Recursive Scanning', () => {
it('should scan single-level directory', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('Task1.md', 'file'),
createDirent('Task2.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(2);
expect(tree[0].name).toBe('Task1');
expect(tree[0].type).toBe('file');
expect(tree[1].name).toBe('Task2');
});
it('should scan nested directories recursively', async () => {
// Root level
mockReaddir.mockResolvedValueOnce([
createDirent('Phase1', 'directory'),
createDirent('Overview.md', 'file'),
]);
// Phase1 subdirectory
mockReaddir.mockResolvedValueOnce([
createDirent('Task1.md', 'file'),
createDirent('Task2.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(2);
expect(tree[0].type).toBe('folder');
expect(tree[0].name).toBe('Phase1');
expect(tree[0].children).toHaveLength(2);
expect(tree[1].type).toBe('file');
expect(tree[1].name).toBe('Overview');
});
it('should scan deeply nested structures', async () => {
// Root
mockReaddir.mockResolvedValueOnce([createDirent('Level1', 'directory')]);
// Level1
mockReaddir.mockResolvedValueOnce([createDirent('Level2', 'directory')]);
// Level2
mockReaddir.mockResolvedValueOnce([createDirent('Level3', 'directory')]);
// Level3
mockReaddir.mockResolvedValueOnce([createDirent('DeepDoc.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].type).toBe('folder');
expect(tree[0].children?.[0].type).toBe('folder');
expect(tree[0].children?.[0].children?.[0].type).toBe('folder');
expect(tree[0].children?.[0].children?.[0].children?.[0].type).toBe('file');
expect(tree[0].children?.[0].children?.[0].children?.[0].name).toBe('DeepDoc');
});
it('should build correct relative paths for nested files', async () => {
// Root
mockReaddir.mockResolvedValueOnce([createDirent('Phase1', 'directory')]);
// Phase1
mockReaddir.mockResolvedValueOnce([createDirent('SubPhase', 'directory')]);
// SubPhase
mockReaddir.mockResolvedValueOnce([createDirent('Task.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
const deepFile = tree[0].children?.[0].children?.[0];
expect(deepFile?.path).toBe('Phase1/SubPhase/Task');
});
});
describe('Empty Folder Handling', () => {
it('should exclude empty folders from tree', async () => {
// Root
mockReaddir.mockResolvedValueOnce([
createDirent('EmptyFolder', 'directory'),
createDirent('Document.md', 'file'),
]);
// EmptyFolder - no .md files
mockReaddir.mockResolvedValueOnce([]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].type).toBe('file');
expect(tree[0].name).toBe('Document');
});
it('should exclude folders with only non-md files', async () => {
// Root
mockReaddir.mockResolvedValueOnce([createDirent('ImagesFolder', 'directory')]);
// ImagesFolder - only images
mockReaddir.mockResolvedValueOnce([
createDirent('photo.png', 'file'),
createDirent('screenshot.jpg', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(0);
});
it('should include folder if any nested folder has md files', async () => {
// Root
mockReaddir.mockResolvedValueOnce([createDirent('ParentFolder', 'directory')]);
// ParentFolder - no direct .md files, but has subfolder
mockReaddir.mockResolvedValueOnce([createDirent('NestedFolder', 'directory')]);
// NestedFolder - has .md files
mockReaddir.mockResolvedValueOnce([createDirent('Task.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('ParentFolder');
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children?.[0].name).toBe('NestedFolder');
});
it('should handle completely empty directory', async () => {
mockReaddir.mockResolvedValueOnce([]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(0);
});
});
describe('Sorting Behavior', () => {
it('should sort folders before files', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('ZFile.md', 'file'),
createDirent('AFolder', 'directory'),
createDirent('AFile.md', 'file'),
createDirent('ZFolder', 'directory'),
]);
// Both folders must be scanned
mockReaddir.mockResolvedValueOnce([createDirent('nested.md', 'file')]);
mockReaddir.mockResolvedValueOnce([createDirent('nested.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
// Folders first (alphabetically), then files (alphabetically)
expect(tree[0].type).toBe('folder');
expect(tree[0].name).toBe('AFolder');
expect(tree[1].type).toBe('folder');
expect(tree[1].name).toBe('ZFolder');
expect(tree[2].type).toBe('file');
expect(tree[2].name).toBe('AFile');
expect(tree[3].type).toBe('file');
expect(tree[3].name).toBe('ZFile');
});
it('should sort alphabetically case-insensitive', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('zebra.md', 'file'),
createDirent('Alpha.md', 'file'),
createDirent('BETA.md', 'file'),
createDirent('gamma.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree.map((n) => n.name)).toEqual(['Alpha', 'BETA', 'gamma', 'zebra']);
});
it('should handle numeric prefixes in names', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('10-Task.md', 'file'),
createDirent('2-Task.md', 'file'),
createDirent('1-Task.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
// String sort: "1-Task" < "10-Task" < "2-Task"
expect(tree.map((n) => n.name)).toEqual(['1-Task', '10-Task', '2-Task']);
});
});
describe('File Extension Handling', () => {
it('should only include .md files', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('document.md', 'file'),
createDirent('readme.txt', 'file'),
createDirent('config.json', 'file'),
createDirent('script.js', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('document');
});
it('should handle case-insensitive .md extension', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('lower.md', 'file'),
createDirent('upper.MD', 'file'),
createDirent('mixed.Md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(3);
});
it('should remove .md extension from name and path', async () => {
mockReaddir.mockResolvedValueOnce([createDirent('Phase1', 'directory')]);
mockReaddir.mockResolvedValueOnce([createDirent('Task1.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree[0].children?.[0].name).toBe('Task1');
expect(tree[0].children?.[0].path).toBe('Phase1/Task1');
// Path should NOT end with .md
expect(tree[0].children?.[0].path.endsWith('.md')).toBe(false);
});
it('should handle files with .md in the name before extension', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('readme.md.md', 'file'),
createDirent('CLAUDE.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(2);
// "readme.md.md" becomes "readme.md" after removing extension
expect(tree.map((n) => n.name).sort()).toEqual(['CLAUDE', 'readme.md']);
});
});
});
describe('Hidden File Filtering', () => {
describe('Basic Hidden File Detection', () => {
it('should identify files starting with dot as hidden', () => {
expect(isHiddenFile('.hidden')).toBe(true);
expect(isHiddenFile('.DS_Store')).toBe(true);
expect(isHiddenFile('.gitignore')).toBe(true);
expect(isHiddenFile('.env')).toBe(true);
});
it('should not flag normal files as hidden', () => {
expect(isHiddenFile('document.md')).toBe(false);
expect(isHiddenFile('Phase1')).toBe(false);
expect(isHiddenFile('README.md')).toBe(false);
});
it('should not flag files with dots in the middle as hidden', () => {
expect(isHiddenFile('file.test.md')).toBe(false);
expect(isHiddenFile('my.document.md')).toBe(false);
expect(isHiddenFile('v1.0.0')).toBe(false);
});
});
describe('Filtering in Directory Scan', () => {
it('should filter out hidden files', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.hidden.md', 'file'),
createDirent('visible.md', 'file'),
createDirent('.DS_Store', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('visible');
});
it('should filter out hidden folders', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.git', 'directory'),
createDirent('.vscode', 'directory'),
createDirent('Phase1', 'directory'),
]);
// Only Phase1 is scanned
mockReaddir.mockResolvedValueOnce([createDirent('Task.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('Phase1');
});
it('should filter hidden files in nested directories', async () => {
mockReaddir.mockResolvedValueOnce([createDirent('Phase1', 'directory')]);
mockReaddir.mockResolvedValueOnce([
createDirent('.hidden.md', 'file'),
createDirent('Task1.md', 'file'),
createDirent('.draft.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children?.[0].name).toBe('Task1');
});
it('should not scan inside hidden folders', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.hidden-folder', 'directory'),
createDirent('visible.md', 'file'),
]);
// .hidden-folder should not be scanned
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
// mockReaddir should only be called once (for root)
expect(mockReaddir).toHaveBeenCalledTimes(1);
});
});
describe('Common Hidden File Patterns', () => {
it('should filter macOS system files', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.DS_Store', 'file'),
createDirent('.Spotlight-V100', 'directory'),
createDirent('.Trashes', 'directory'),
createDirent('.fseventsd', 'directory'),
createDirent('document.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('document');
});
it('should filter version control folders', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.git', 'directory'),
createDirent('.svn', 'directory'),
createDirent('.hg', 'directory'),
createDirent('README.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('README');
});
it('should filter IDE configuration folders', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.vscode', 'directory'),
createDirent('.idea', 'directory'),
createDirent('.cursor', 'directory'),
createDirent('.claude', 'directory'),
createDirent('Project.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('Project');
});
it('should filter environment and configuration files', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.env', 'file'),
createDirent('.env.local', 'file'),
createDirent('.npmrc', 'file'),
createDirent('.gitignore', 'file'),
createDirent('.eslintrc', 'file'),
createDirent('Task.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('Task');
});
});
describe('Edge Cases', () => {
it('should handle file named just "."', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.', 'file'), // Edge case
createDirent('normal.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('normal');
});
it('should handle file named ".."', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('..', 'file'), // Edge case
createDirent('document.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('document');
});
it('should handle files with multiple leading dots', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('..hidden', 'file'),
createDirent('...alsohidden', 'file'),
createDirent('visible.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe('visible');
});
});
});
describe('Symlink Handling', () => {
describe('Symlink Detection', () => {
it('should identify symbolic links via dirent', () => {
const symlink = createDirent('link-to-file', 'symlink');
const regular = createDirent('regular-file.md', 'file');
expect(symlink.isSymbolicLink()).toBe(true);
expect(regular.isSymbolicLink()).toBe(false);
});
it('should differentiate symlinks from regular files', () => {
const symlink = createDirent('link.md', 'symlink');
// Symlinks report false for isFile() and isDirectory() by default
// unless you use lstat vs stat
expect(symlink.isFile()).toBe(false);
expect(symlink.isDirectory()).toBe(false);
expect(symlink.isSymbolicLink()).toBe(true);
});
});
describe('Symlink Security', () => {
it('should use realpath to resolve symlink targets', async () => {
mockRealpath.mockResolvedValue('/actual/resolved/path');
const resolved = await mockRealpath('/test/autorun/symlink');
expect(resolved).toBe('/actual/resolved/path');
});
it('should detect symlink pointing outside folder', async () => {
const folderPath = '/test/autorun';
// Symlink /test/autorun/evil-link -> /etc
mockRealpath.mockResolvedValue('/etc/passwd');
const resolvedTarget = await mockRealpath('/test/autorun/evil-link');
expect(validatePathWithinFolder(resolvedTarget, folderPath)).toBe(false);
});
it('should allow symlink pointing within folder', async () => {
const folderPath = '/test/autorun';
// Symlink /test/autorun/shortcut -> /test/autorun/subfolder/doc.md
mockRealpath.mockResolvedValue('/test/autorun/subfolder/doc.md');
const resolvedTarget = await mockRealpath('/test/autorun/shortcut');
expect(validatePathWithinFolder(resolvedTarget, folderPath)).toBe(true);
});
it('should handle symlink chains', async () => {
// link1 -> link2 -> link3 -> final.md
// realpath resolves the entire chain
mockRealpath.mockResolvedValue('/test/autorun/final.md');
const resolved = await mockRealpath('/test/autorun/link1');
expect(validatePathWithinFolder(resolved, '/test/autorun')).toBe(true);
});
it('should handle circular symlinks', async () => {
// link1 -> link2 -> link1 (circular)
mockRealpath.mockRejectedValue(new Error('ELOOP: too many levels of symbolic links'));
await expect(mockRealpath('/test/autorun/circular-link')).rejects.toThrow('ELOOP');
});
it('should handle broken symlinks', async () => {
// Symlink pointing to non-existent target
mockRealpath.mockRejectedValue(new Error('ENOENT: no such file or directory'));
await expect(mockRealpath('/test/autorun/broken-link')).rejects.toThrow('ENOENT');
});
});
describe('Symlink to Directory', () => {
it('should handle symlink to directory within folder', async () => {
const folderPath = '/test/autorun';
mockRealpath.mockResolvedValue('/test/autorun/real-folder');
const resolved = await mockRealpath('/test/autorun/folder-link');
expect(validatePathWithinFolder(resolved, folderPath)).toBe(true);
});
it('should detect symlink to directory outside folder', async () => {
const folderPath = '/test/autorun';
// Dangerous: symlink to system directory
mockRealpath.mockResolvedValue('/usr/share');
const resolved = await mockRealpath('/test/autorun/system-link');
expect(validatePathWithinFolder(resolved, folderPath)).toBe(false);
});
it('should handle relative symlinks', async () => {
const folderPath = '/test/autorun';
// Relative symlink: ../sibling -> /test/sibling (outside)
mockRealpath.mockResolvedValue('/test/sibling');
const resolved = await mockRealpath('/test/autorun/relative-link');
expect(validatePathWithinFolder(resolved, folderPath)).toBe(false);
});
});
describe('lstat vs stat Behavior', () => {
it('should use lstat to check if entry is symlink', async () => {
// lstat returns info about the link itself
mockLstat.mockResolvedValue({
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true,
});
const lstats = await mockLstat('/test/autorun/link');
expect(lstats.isSymbolicLink()).toBe(true);
});
it('should use stat to check symlink target type', async () => {
// stat follows symlinks and returns info about target
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false, // stat always returns false for isSymbolicLink
});
const stats = await mockStat('/test/autorun/link-to-file');
expect(stats.isFile()).toBe(true);
});
it('should handle stat on broken symlink', async () => {
// stat fails on broken symlinks
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
// lstat succeeds - the link exists, target doesn't
mockLstat.mockResolvedValue({
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true,
});
await expect(mockStat('/test/autorun/broken-link')).rejects.toThrow('ENOENT');
const lstats = await mockLstat('/test/autorun/broken-link');
expect(lstats.isSymbolicLink()).toBe(true);
});
});
describe('Safe Symlink Handling Strategy', () => {
it('should validate complete path chain', () => {
// Strategy: For any path, resolve and validate the final target
const validateSymlinkPath = (
requestedPath: string,
resolvedPath: string,
folderPath: string
): boolean => {
// Both the requested path AND resolved path must be within folder
return (
validatePathWithinFolder(requestedPath, folderPath) &&
validatePathWithinFolder(resolvedPath, folderPath)
);
};
const folderPath = '/test/autorun';
// Case 1: Both paths valid
expect(
validateSymlinkPath('/test/autorun/link', '/test/autorun/target.md', folderPath)
).toBe(true);
// Case 2: Requested valid, resolved invalid (symlink escape)
expect(validateSymlinkPath('/test/autorun/evil-link', '/etc/passwd', folderPath)).toBe(
false
);
// Case 3: Requested invalid
expect(
validateSymlinkPath('/etc/autorun/link', '/test/autorun/target.md', folderPath)
).toBe(false);
});
it('should handle symlink race conditions (TOCTOU)', () => {
// Time-of-check to time-of-use vulnerability
// Solution: Always validate at the point of file operation
const performSecureRead = async (
requestedPath: string,
folderPath: string,
resolveFn: typeof mockRealpath
): Promise<{ valid: boolean; resolvedPath?: string }> => {
try {
const resolved = await resolveFn(requestedPath);
const valid = validatePathWithinFolder(resolved, folderPath);
return { valid, resolvedPath: valid ? resolved : undefined };
} catch {
return { valid: false };
}
};
// This pattern ensures we always validate the actual target
expect(performSecureRead).toBeDefined();
});
});
});
describe('Combined Scenarios', () => {
describe('Complex Directory Structures', () => {
it('should handle mix of hidden, visible, symlinks, and nested', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('.git', 'directory'),
createDirent('Phase1', 'directory'),
createDirent('.hidden.md', 'file'),
createDirent('README.md', 'file'),
createDirent('link-to-doc', 'symlink'), // Symlinks are typically excluded
]);
mockReaddir.mockResolvedValueOnce([
createDirent('.drafts', 'directory'),
createDirent('Task1.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
// Only visible non-symlink items
expect(tree).toHaveLength(2);
expect(tree.map((n) => n.name).sort()).toEqual(['Phase1', 'README']);
expect(tree.find((n) => n.name === 'Phase1')?.children).toHaveLength(1);
});
it('should handle very long path names', async () => {
const longName = 'a'.repeat(200) + '.md';
mockReaddir.mockResolvedValueOnce([createDirent(longName, 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1);
expect(tree[0].name.length).toBe(200);
});
it('should handle special characters in filenames', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('doc with spaces.md', 'file'),
createDirent('doc-with-dashes.md', 'file'),
createDirent('doc_with_underscores.md', 'file'),
createDirent('doc.with.dots.md', 'file'),
createDirent("doc'with'quotes.md", 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(5);
});
it('should handle unicode filenames', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('文档.md', 'file'),
createDirent('документ.md', 'file'),
createDirent('αβγδ.md', 'file'),
createDirent('🎉emoji🎉.md', 'file'),
]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(4);
});
});
describe('Error Handling During Scan', () => {
it('should handle permission denied on subfolder', async () => {
mockReaddir.mockResolvedValueOnce([
createDirent('accessible', 'directory'),
createDirent('restricted', 'directory'),
]);
mockReaddir.mockResolvedValueOnce([createDirent('doc.md', 'file')]);
mockReaddir.mockRejectedValueOnce(new Error('EACCES: permission denied'));
// The implementation would need error handling for this
// Current simple implementation would throw
await expect(scanDirectory('/test/autorun', '', mockReaddir)).rejects.toThrow('EACCES');
});
it('should handle folder disappearing during scan', async () => {
mockReaddir.mockResolvedValueOnce([createDirent('volatile', 'directory')]);
mockReaddir.mockRejectedValueOnce(new Error('ENOENT: no such file or directory'));
await expect(scanDirectory('/test/autorun', '', mockReaddir)).rejects.toThrow('ENOENT');
});
});
describe('Performance Considerations', () => {
it('should handle directory with many files', async () => {
const manyFiles = Array.from({ length: 1000 }, (_, i) =>
createDirent(`file-${i.toString().padStart(4, '0')}.md`, 'file')
);
mockReaddir.mockResolvedValueOnce(manyFiles);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(tree).toHaveLength(1000);
});
it('should handle deeply nested structure efficiently', async () => {
// Create 10 levels of nesting
for (let i = 0; i < 10; i++) {
mockReaddir.mockResolvedValueOnce([createDirent(`Level${i}`, 'directory')]);
}
// Deepest level has a file
mockReaddir.mockResolvedValueOnce([createDirent('DeepFile.md', 'file')]);
const tree = await scanDirectory('/test/autorun', '', mockReaddir);
expect(mockReaddir).toHaveBeenCalledTimes(11);
// Traverse to verify structure
let current = tree[0];
for (let i = 1; i < 10; i++) {
expect(current.children).toHaveLength(1);
current = current.children![0];
}
expect(current.children?.[0].type).toBe('file');
});
});
});
});