agent-ecosystem/src/main/services/editor/ProjectFileService.ts
iliya e1585b4f71 feat: add binary file preview functionality and improve editor performance
- Introduced a new IPC handler for reading binary files as base64 for inline previews, enhancing the editor's capability to display images directly.
- Implemented a placeholder component for non-previewable binary files, providing users with file information and an option to open in the system viewer.
- Enhanced the EditorFileWatcher to ignore permission errors and adjusted debounce timing for better performance during file system events.
- Updated the ProjectFileService to include a method for reading binary previews with security checks and MIME type validation.
- Improved the EditorImagePreview component to handle loading states and display images with a checkerboard background for transparency.
- Refactored the EditorBinaryState component to utilize the new binary preview functionality, streamlining the display of binary files.
2026-03-01 23:51:51 +02:00

749 lines
22 KiB
TypeScript

/**
* Stateless file service for the project editor.
*
* Every method receives `projectRoot` as the first argument.
* Security: path containment, symlink escape detection, device path blocking,
* binary detection, and size limits are enforced on every call.
*/
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import {
isDevicePath,
isGitInternalPath,
isPathWithinAllowedDirectories,
isPathWithinRoot,
matchesSensitivePattern,
validateFileName,
validateFilePath,
} from '@main/utils/pathValidation';
import { createLogger } from '@shared/utils/logger';
import { shell } from 'electron';
import * as fs from 'fs/promises';
import { isBinaryFile } from 'isbinaryfile';
import * as path from 'path';
import type {
BinaryPreviewResult,
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
FileTreeEntry,
MoveFileResponse,
ReadDirResult,
ReadFileResult,
WriteFileResponse,
} from '@shared/types/editor';
// =============================================================================
// Constants
// =============================================================================
const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB
const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB
const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
const MAX_DIR_ENTRIES = 500;
const PREVIEW_LINE_COUNT = 100;
/**
* Extract the first N lines from text using indexOf — O(1) allocations vs split().
* For a 5MB file with 100k lines, avoids creating 100k string objects.
*/
function sliceFirstNLines(text: string, n: number): string {
let pos = 0;
for (let i = 0; i < n; i++) {
const next = text.indexOf('\n', pos);
if (next === -1) return text;
pos = next + 1;
}
return text.slice(0, pos > 0 ? pos - 1 : 0);
}
const PREVIEW_MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
};
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
const IGNORED_DIRS = new Set([
'.git',
'node_modules',
'.next',
'dist',
'__pycache__',
'.cache',
'.venv',
'.tox',
'vendor',
]);
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
const log = createLogger('ProjectFileService');
// =============================================================================
// Service
// =============================================================================
export class ProjectFileService {
/**
* Read a directory listing (depth=1, lazy loading).
*
* Security:
* - Containment via isPathWithinAllowedDirectories (NOT validateFilePath — sensitive files
* are shown with isSensitive flag, not filtered)
* - Symlinks: realpath + re-check containment, silently skip escapes (SEC-2)
*/
async readDir(
projectRoot: string,
dirPath: string,
maxEntries: number = MAX_DIR_ENTRIES
): Promise<ReadDirResult> {
const t0 = performance.now();
const normalizedDir = path.resolve(dirPath);
// Containment check (allow sensitive files to be listed with flag)
if (!isPathWithinAllowedDirectories(normalizedDir, projectRoot)) {
throw new Error('Directory is outside project root');
}
const stat = await fs.lstat(normalizedDir);
if (!stat.isDirectory()) {
throw new Error('Not a directory');
}
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
// Phase 1: classify entries without I/O (instant)
const pendingEntries: {
dirent: { name: string };
entryPath: string;
type: 'file' | 'directory' | 'symlink';
}[] = [];
for (const dirent of dirents) {
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
const entryPath = path.join(normalizedDir, dirent.name);
if (!isPathWithinRoot(entryPath, projectRoot)) continue;
if (isGitInternalPath(entryPath)) continue;
if (dirent.isSymbolicLink()) {
pendingEntries.push({ dirent, entryPath, type: 'symlink' });
} else if (dirent.isDirectory()) {
pendingEntries.push({ dirent, entryPath, type: 'directory' });
} else if (dirent.isFile()) {
pendingEntries.push({ dirent, entryPath, type: 'file' });
}
if (pendingEntries.length >= maxEntries) break;
}
// Phase 2: resolve entries in parallel (I/O-bound)
const STAT_CONCURRENCY = 50;
const entries: FileTreeEntry[] = [];
for (let i = 0; i < pendingEntries.length; i += STAT_CONCURRENCY) {
const batch = pendingEntries.slice(i, i + STAT_CONCURRENCY);
const resolved = await Promise.all(
batch.map(async ({ dirent, entryPath, type }) => {
if (type === 'directory') {
return this.buildEntry(dirent.name, entryPath, 'directory');
}
if (type === 'symlink') {
return this.resolveSymlinkEntry(dirent.name, entryPath, projectRoot);
}
// file — stat for size
try {
const fileStat = await fs.stat(entryPath);
return this.buildEntry(dirent.name, entryPath, 'file', fileStat.size);
} catch {
return this.buildEntry(dirent.name, entryPath, 'file');
}
})
);
for (const entry of resolved) {
if (entry) entries.push(entry);
}
}
// Sort: directories first, then alphabetical
entries.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
const totalMs = performance.now() - t0;
if (totalMs > 50) {
log.info(
`[perf] readDir: ${totalMs.toFixed(1)}ms, entries=${entries.length}, dirents=${dirents.length}, dir=${path.basename(normalizedDir)}`
);
}
return { entries, truncated: pendingEntries.length >= maxEntries };
}
/**
* Read file content with security checks and binary detection.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Device path blocking (SEC-4)
* - lstat + isFile check (SEC-4)
* - Size limits (SEC-4)
* - Post-read TOCTOU realpath verify (SEC-3)
*/
async readFile(projectRoot: string, filePath: string): Promise<ReadFileResult> {
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot read device files');
}
// 3. File type check
const stats = await fs.lstat(normalizedPath);
if (!stats.isFile()) {
throw new Error('Not a regular file');
}
// 4. Size check — reject files beyond preview limit
if (stats.size > MAX_FILE_SIZE_PREVIEW) {
throw new Error(
`File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB). Open in external editor.`
);
}
// 5. Binary check
const binary = await isBinaryFile(normalizedPath);
if (binary) {
return {
content: '',
size: stats.size,
mtimeMs: stats.mtimeMs,
truncated: false,
encoding: 'binary',
isBinary: true,
};
}
// 6. Read content
const raw = await fs.readFile(normalizedPath, 'utf8');
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(normalizedPath);
const postValidation = validateFilePath(realPath, projectRoot);
if (!postValidation.valid) {
throw new Error('Path changed during read (TOCTOU)');
}
// 8. Tiered response
const isPreview = stats.size > MAX_FILE_SIZE_FULL;
const content = isPreview ? sliceFirstNLines(raw, PREVIEW_LINE_COUNT) : raw;
return {
content,
size: stats.size,
mtimeMs: stats.mtimeMs,
truncated: isPreview,
encoding: 'utf-8',
isBinary: false,
};
}
/**
* Write file content with atomic write and full security checks.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Project-only containment — block writes outside projectRoot (SEC-14)
* - Block .git/ internal paths (SEC-12)
* - Device path blocking (SEC-4)
* - Content size limit (2MB)
* - Atomic write via tmp + rename (SEC-9)
*/
async writeFile(
projectRoot: string,
filePath: string,
content: string
): Promise<WriteFileResponse> {
// 1. Path validation
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Project-only containment (SEC-14: block ~/.claude writes)
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 3. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(normalizedPath)) {
throw new Error('Cannot write to .git/ directory');
}
// 4. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot write to device files');
}
// 5. Content size check
const byteLength = Buffer.byteLength(content, 'utf8');
if (byteLength > MAX_WRITE_SIZE) {
throw new Error(
`Content too large (${(byteLength / 1024 / 1024).toFixed(1)}MB). Maximum is 2MB.`
);
}
// 6. Atomic write
await atomicWriteAsync(normalizedPath, content);
// 7. Get post-write stats
const stats = await fs.stat(normalizedPath);
log.info('File saved:', normalizedPath, `(${stats.size} bytes)`);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
}
/**
* Create a new empty file.
*
* Security:
* - validateFileName for traversal, control chars (SEC-1)
* - validateFilePath for parent containment (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ writes (SEC-12)
* - Check parent is directory, file does NOT exist
*/
async createFile(
projectRoot: string,
parentDir: string,
fileName: string
): Promise<CreateFileResponse> {
// 1. Validate file name
const nameValidation = validateFileName(fileName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate parent directory path
const parentValidation = validateFilePath(parentDir, projectRoot);
if (!parentValidation.valid) {
throw new Error(parentValidation.error);
}
const normalizedParent = parentValidation.normalizedPath!;
// 3. Build full path
const fullPath = path.join(normalizedParent, fileName.trim());
// 4. Project-only containment (SEC-14)
if (!isPathWithinRoot(fullPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 5. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(fullPath)) {
throw new Error('Cannot create files in .git/ directory');
}
// 6. Verify parent is a directory
const parentStat = await fs.lstat(normalizedParent);
if (!parentStat.isDirectory()) {
throw new Error('Parent path is not a directory');
}
// 7. Verify file does NOT exist
try {
await fs.access(fullPath);
throw new Error('File already exists');
} catch (err) {
// Expected: ENOENT means file doesn't exist (good)
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err; // Re-throw 'File already exists' or other errors
}
}
// 8. Create empty file
await fs.writeFile(fullPath, '', 'utf8');
// 9. Get stats
const stats = await fs.stat(fullPath);
log.info('File created:', fullPath);
return { filePath: fullPath, mtimeMs: stats.mtimeMs };
}
/**
* Create a new directory.
*
* Same security checks as createFile, but uses fs.mkdir.
*/
async createDir(
projectRoot: string,
parentDir: string,
dirName: string
): Promise<CreateDirResponse> {
// 1. Validate directory name
const nameValidation = validateFileName(dirName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate parent directory path
const parentValidation = validateFilePath(parentDir, projectRoot);
if (!parentValidation.valid) {
throw new Error(parentValidation.error);
}
const normalizedParent = parentValidation.normalizedPath!;
// 3. Build full path
const fullPath = path.join(normalizedParent, dirName.trim());
// 4. Project-only containment (SEC-14)
if (!isPathWithinRoot(fullPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 5. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(fullPath)) {
throw new Error('Cannot create directories in .git/ directory');
}
// 6. Verify parent is a directory
const parentStat = await fs.lstat(normalizedParent);
if (!parentStat.isDirectory()) {
throw new Error('Parent path is not a directory');
}
// 7. Verify directory does NOT exist
try {
await fs.access(fullPath);
throw new Error('Directory already exists');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 8. Create directory
await fs.mkdir(fullPath);
log.info('Directory created:', fullPath);
return { dirPath: fullPath };
}
/**
* Delete a file or directory by moving it to the system Trash.
*
* Security:
* - validateFilePath for containment (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ deletes (SEC-12)
* - Uses shell.trashItem for safe, reversible deletion
*/
async deleteFile(projectRoot: string, filePath: string): Promise<DeleteFileResponse> {
// 1. Validate file path
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Project-only containment (SEC-14)
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 3. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(normalizedPath)) {
throw new Error('Cannot delete files in .git/ directory');
}
// 4. Verify path exists
await fs.lstat(normalizedPath);
// 5. Move to Trash (safe, reversible)
await shell.trashItem(normalizedPath);
log.info('File moved to Trash:', normalizedPath);
return { deletedPath: normalizedPath };
}
/**
* Move a file or directory to a new location within the project.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ moves (SEC-12)
* - Parent → child move prevention
* - Name collision detection
* - EXDEV cross-device fallback (fs.cp + fs.rm)
*/
async moveFile(
projectRoot: string,
sourcePath: string,
destDir: string
): Promise<MoveFileResponse> {
// 1. Validate source path
const srcValidation = validateFilePath(sourcePath, projectRoot);
if (!srcValidation.valid) {
throw new Error(srcValidation.error);
}
const normalizedSrc = srcValidation.normalizedPath!;
// 2. Validate dest directory path
const destValidation = validateFilePath(destDir, projectRoot);
if (!destValidation.valid) {
throw new Error(destValidation.error);
}
const normalizedDest = destValidation.normalizedPath!;
// 3. Project containment (SEC-14)
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
throw new Error('Source path is outside project root');
}
if (!isPathWithinRoot(normalizedDest, projectRoot)) {
throw new Error('Destination path is outside project root');
}
// 4. Block .git/ paths (SEC-12)
if (isGitInternalPath(normalizedSrc)) {
throw new Error('Cannot move files from .git/ directory');
}
if (isGitInternalPath(normalizedDest)) {
throw new Error('Cannot move files into .git/ directory');
}
// 5. Verify source exists and determine type
const srcStat = await fs.lstat(normalizedSrc);
const isDirectory = srcStat.isDirectory();
// 6. Verify destination is a directory
const destStat = await fs.lstat(normalizedDest);
if (!destStat.isDirectory()) {
throw new Error('Destination is not a directory');
}
// 7. Build new path
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
// 8. Prevent parent → child move (moving dir into itself)
if (normalizedDest.startsWith(normalizedSrc + path.sep) || normalizedDest === normalizedSrc) {
throw new Error('Cannot move a directory into itself');
}
// 9. Check destination doesn't already exist
try {
await fs.access(newPath);
throw new Error('File already exists at destination');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 10. Block sensitive destination
if (matchesSensitivePattern(newPath)) {
throw new Error('Cannot move to sensitive file location');
}
// 11. Perform rename with EXDEV fallback
try {
await fs.rename(normalizedSrc, newPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
// Reuse srcStat from step 5 — no need for another fs.lstat
if (isDirectory) {
await fs.cp(normalizedSrc, newPath, { recursive: true });
} else {
await fs.copyFile(normalizedSrc, newPath);
}
await fs.rm(normalizedSrc, { recursive: true, force: true });
} else {
throw err;
}
}
log.info('File moved:', normalizedSrc, '→', newPath);
return { newPath, isDirectory };
}
/**
* Rename a file or directory in place (same parent directory).
*/
async renameFile(
projectRoot: string,
sourcePath: string,
newName: string
): Promise<MoveFileResponse> {
// 1. Validate new name
const nameValidation = validateFileName(newName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate source path
const srcValidation = validateFilePath(sourcePath, projectRoot);
if (!srcValidation.valid) {
throw new Error(srcValidation.error);
}
const normalizedSrc = srcValidation.normalizedPath!;
// 3. Project containment
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
throw new Error('Source path is outside project root');
}
// 4. Block .git/ paths
if (isGitInternalPath(normalizedSrc)) {
throw new Error('Cannot rename files in .git/ directory');
}
// 5. Verify source exists
const srcStat = await fs.lstat(normalizedSrc);
const isDirectory = srcStat.isDirectory();
// 6. Build new path (same parent, new name)
const parentDir = path.dirname(normalizedSrc);
const newPath = path.join(parentDir, newName);
// 7. Check new path doesn't already exist
try {
await fs.access(newPath);
throw new Error('A file or folder with that name already exists');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 8. Block sensitive destination
if (matchesSensitivePattern(newPath)) {
throw new Error('Cannot rename to a sensitive file name');
}
// 9. Perform rename
await fs.rename(normalizedSrc, newPath);
log.info('File renamed:', normalizedSrc, '→', newPath);
return { newPath, isDirectory };
}
/**
* Read a binary file as base64 for inline preview (images, etc.).
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Device path blocking (SEC-4)
* - lstat + isFile check (SEC-4)
* - Size limit (10MB)
* - Post-read TOCTOU realpath verify (SEC-3)
*/
async readBinaryPreview(projectRoot: string, filePath: string): Promise<BinaryPreviewResult> {
// 1. Path validation
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot read device files');
}
// 3. File type check
const stats = await fs.lstat(normalizedPath);
if (!stats.isFile()) {
throw new Error('Not a regular file');
}
// 4. Size check
if (stats.size > MAX_PREVIEW_SIZE) {
throw new Error(
`File too large for preview (${(stats.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.`
);
}
// 5. MIME type from extension
const ext = path.extname(normalizedPath).toLowerCase();
const mimeType = PREVIEW_MIME_MAP[ext];
if (!mimeType) {
throw new Error(`Unsupported preview format: ${ext}`);
}
// 6. Read file as Buffer → base64
const buffer = await fs.readFile(normalizedPath);
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(normalizedPath);
const postValidation = validateFilePath(realPath, projectRoot);
if (!postValidation.valid) {
throw new Error('Path changed during read (TOCTOU)');
}
return {
base64: buffer.toString('base64'),
mimeType,
size: stats.size,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private async resolveSymlinkEntry(
name: string,
entryPath: string,
projectRoot: string
): Promise<FileTreeEntry | null> {
try {
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) return null;
const realStat = await fs.stat(realPath);
return this.buildEntry(
name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
} catch {
return null; // broken symlink
}
}
private buildEntry(
name: string,
entryPath: string,
type: 'file' | 'directory',
size?: number
): FileTreeEntry {
const entry: FileTreeEntry = { name, path: entryPath, type };
if (size !== undefined) entry.size = size;
if (matchesSensitivePattern(entryPath)) entry.isSensitive = true;
return entry;
}
}
export { MAX_DIR_ENTRIES, MAX_FILE_SIZE_FULL, MAX_FILE_SIZE_PREVIEW, MAX_WRITE_SIZE };