file-handler.tsā¢13.5 kB
import {
readFileSync,
writeFileSync,
existsSync,
statSync,
accessSync,
constants,
openSync,
closeSync,
readSync,
} from 'fs';
import { resolve } from 'path';
import type { Stats } from 'fs';
import type { PathAccessControl } from './path-access-control.js';
export interface ReadLinesResult {
content: string;
lines: string[];
lineEnding: string;
totalLines: number;
}
export interface FileSnapshot {
filePath: string;
content: string;
lineCount: number;
timestamp: number;
}
/**
* Handles file operations with line-based manipulation
* Supports reading, writing, inserting, and deleting lines from text files
*/
export class FileHandler {
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB limit
private readonly BINARY_CHECK_BYTES = 8000; // Check first 8KB for binary detection
private pathAccessControl: PathAccessControl | null;
constructor(pathAccessControl?: PathAccessControl) {
this.pathAccessControl = pathAccessControl || null;
}
/**
* Read a specific range of lines from a file (1-indexed)
* @param filePath - Path to the file (absolute or relative)
* @param startLine - Starting line number (1-indexed, inclusive)
* @param endLine - Ending line number (1-indexed, inclusive)
* @returns Object containing content, lines array, and metadata
*/
readLines(filePath: string, startLine: number, endLine: number): ReadLinesResult {
// Resolve and validate inputs
const resolvedPath = resolve(filePath);
this.validatePathAccess(resolvedPath);
this.validateFilePath(resolvedPath);
if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) {
throw new Error('Line numbers must be integers');
}
if (startLine <= 0 || endLine <= 0) {
throw new Error('Line numbers must be positive (1-indexed)');
}
if (startLine > endLine) {
throw new Error('Invalid line range: start line must be <= end line');
}
const stats = this.ensureFileSizeWithinLimit(resolvedPath);
// Check if file is binary
if (!this.isTextFile(resolvedPath, stats)) {
throw new Error('Binary files are not supported');
}
// Read file content
const content = readFileSync(resolvedPath, 'utf-8');
if (content.length === 0) {
throw new Error('File is empty or line range exceeds file length');
}
// Detect line ending
const lineEnding = this.detectLineEnding(content);
// Split into lines (preserving empty lines)
const allLines = content.split(lineEnding);
// Remove trailing empty line if file ends with newline
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
allLines.pop();
}
const totalLines = allLines.length;
// Validate line range
if (endLine > totalLines) {
throw new Error(
`Line range exceeds file length (file has ${totalLines} lines, requested up to ${endLine})`
);
}
// Extract requested lines (convert from 1-indexed to 0-indexed)
const requestedLines = allLines.slice(startLine - 1, endLine);
return {
content: requestedLines.join(lineEnding),
lines: requestedLines,
lineEnding,
totalLines,
};
}
/**
* Insert lines at a specified position (1-indexed)
* @param filePath - Path to the file (absolute or relative)
* @param insertAtLine - Line number to insert at (1-indexed)
* @param lines - Array of lines to insert
*/
insertLines(filePath: string, insertAtLine: number, lines: string[]): void {
const resolvedPath = resolve(filePath);
this.validatePathAccess(resolvedPath);
this.validateFilePath(resolvedPath);
this.validateWriteAccess(resolvedPath);
this.ensureFileSizeWithinLimit(resolvedPath);
if (lines.length === 0) {
return; // Nothing to insert
}
if (!Number.isInteger(insertAtLine)) {
throw new Error('Insert position must be an integer (use 0 for file start)');
}
// Read current content
const content = readFileSync(resolvedPath, 'utf-8');
const lineEnding = this.detectLineEnding(content);
// Split into lines
const allLines = content.split(lineEnding);
// Remove trailing empty line if present
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
allLines.pop();
}
// Validate insert position (allow 0 for beginning of file)
if (insertAtLine < 0 || insertAtLine > allLines.length + 1) {
throw new Error(
`Insert position exceeds file length (file has ${allLines.length} lines, insert position is ${insertAtLine})`
);
}
// Insert lines (handle line 0 as beginning of file)
if (insertAtLine === 0) {
allLines.splice(0, 0, ...lines);
} else {
// Convert from 1-indexed to 0-indexed
allLines.splice(insertAtLine - 1, 0, ...lines);
}
// Write back to file
const newContent = allLines.join(lineEnding) + (content.endsWith(lineEnding) ? lineEnding : '');
writeFileSync(resolvedPath, newContent, 'utf-8');
}
/**
* Delete a range of lines from a file (1-indexed)
* @param filePath - Path to the file (absolute or relative)
* @param startLine - Starting line number (1-indexed, inclusive)
* @param endLine - Ending line number (1-indexed, inclusive)
*/
deleteLines(filePath: string, startLine: number, endLine: number): void {
const resolvedPath = resolve(filePath);
this.validatePathAccess(resolvedPath);
this.validateFilePath(resolvedPath);
this.validateWriteAccess(resolvedPath);
this.ensureFileSizeWithinLimit(resolvedPath);
if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) {
throw new Error('Line numbers must be integers');
}
if (startLine <= 0 || endLine <= 0) {
throw new Error('Line numbers must be positive (1-indexed)');
}
if (startLine > endLine) {
throw new Error('Invalid line range: start line must be <= end line');
}
// Read current content
const content = readFileSync(resolvedPath, 'utf-8');
const lineEnding = this.detectLineEnding(content);
// Split into lines
const allLines = content.split(lineEnding);
// Remove trailing empty line if present
const hadTrailingNewline = allLines.length > 0 && allLines[allLines.length - 1] === '';
if (hadTrailingNewline) {
allLines.pop();
}
// Validate line range
if (endLine > allLines.length) {
throw new Error(
`Line range exceeds file length (file has ${allLines.length} lines, requested up to ${endLine})`
);
}
// Delete lines (convert from 1-indexed to 0-indexed)
allLines.splice(startLine - 1, endLine - startLine + 1);
// Write back to file
const newContent =
allLines.length > 0 ? allLines.join(lineEnding) + (hadTrailingNewline ? lineEnding : '') : '';
writeFileSync(resolvedPath, newContent, 'utf-8');
}
/**
* Get a complete snapshot of a file
* @param filePath - Path to the file (absolute or relative)
* @returns File snapshot with content and metadata
*/
getFileSnapshot(filePath: string): FileSnapshot {
const resolvedPath = resolve(filePath);
this.validatePathAccess(resolvedPath);
this.validateFilePath(resolvedPath);
this.ensureFileSizeWithinLimit(resolvedPath);
const content = readFileSync(resolvedPath, 'utf-8');
const lineEnding = this.detectLineEnding(content);
const lines = content.split(lineEnding);
// Remove trailing empty line if present
if (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
return {
filePath: resolve(filePath),
content,
lineCount: content.length === 0 ? 0 : lines.length,
timestamp: Date.now(),
};
}
/**
* Check if a file is a text file (not binary)
* @param filePath - Path to the file
* @returns true if the file appears to be text, false if binary
*/
isTextFile(filePath: string, stats?: Stats): boolean {
if (!existsSync(filePath)) {
return false;
}
const fileStats = stats ?? statSync(filePath);
if (fileStats.size === 0) {
return true; // Empty files are considered text
}
const checkLength = Math.min(fileStats.size, this.BINARY_CHECK_BYTES);
const buffer = Buffer.alloc(checkLength);
const fd = openSync(filePath, 'r');
try {
readSync(fd, buffer, 0, checkLength, 0);
} finally {
closeSync(fd);
}
// Check for common binary file signatures
if (this.hasBinarySignature(buffer)) {
return false;
}
// Check for null bytes (strong indicator of binary file)
for (let i = 0; i < checkLength; i++) {
if (buffer[i] === 0) {
return false;
}
}
// Try to decode as UTF-8 to check if it's valid text
try {
const text = buffer.slice(0, checkLength).toString('utf-8');
// Check for replacement characters (indicates invalid UTF-8)
// If we have replacement chars but they're not from the actual UTF-8 replacement sequence
if (text.includes('\uFFFD')) {
// Check if the buffer actually contains the UTF-8 replacement char bytes (EF BF BD)
let hasActualReplacement = false;
for (let i = 0; i < checkLength - 2; i++) {
if (buffer[i] === 0xef && buffer[i + 1] === 0xbf && buffer[i + 2] === 0xbd) {
hasActualReplacement = true;
break;
}
}
// If replacement char appears but isn't in original, it means invalid UTF-8
if (!hasActualReplacement) {
return false;
}
}
// Count control characters (excluding whitespace)
let controlChars = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
// Control characters except tab, newline, carriage return
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
controlChars++;
}
}
// If more than 5% control characters, likely binary
return controlChars / text.length < 0.05;
} catch {
// If UTF-8 decode fails, it's binary
return false;
}
}
/**
* Ensure file size does not exceed maximum limit
* @param filePath - Path to the file
* @param stats - Optional stats object to reuse
* @returns File stats
*/
private ensureFileSizeWithinLimit(filePath: string, stats?: Stats): Stats {
const fileStats = stats ?? statSync(filePath);
if (fileStats.size > this.MAX_FILE_SIZE) {
throw new Error(`File size exceeds ${this.MAX_FILE_SIZE / 1024 / 1024}MB limit`);
}
return fileStats;
}
/**
* Check for common binary file signatures
* @param buffer - File buffer
* @returns true if a binary signature is detected
*/
private hasBinarySignature(buffer: Buffer): boolean {
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
if (buffer.length >= 8) {
if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return true; // PNG file
}
}
// PDF signature: %PDF
if (buffer.length >= 4) {
const pdfSig = buffer.slice(0, 4).toString('ascii');
if (pdfSig === '%PDF') {
return true; // PDF file
}
}
// JPEG signature: FF D8 FF
if (buffer.length >= 3) {
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return true; // JPEG file
}
}
// GIF signature: GIF87a or GIF89a
if (buffer.length >= 6) {
const gifSig = buffer.slice(0, 6).toString('ascii');
if (gifSig === 'GIF87a' || gifSig === 'GIF89a') {
return true; // GIF file
}
}
// ZIP/DOCX/JAR signature: PK
if (buffer.length >= 2) {
if (buffer[0] === 0x50 && buffer[1] === 0x4b) {
return true; // ZIP-based file
}
}
return false;
}
/**
* Detect the line ending style used in a file
* @param content - File content
* @returns The detected line ending (\n, \r\n, or \n as default)
*/
private detectLineEnding(content: string): string {
if (content.includes('\r\n')) {
return '\r\n';
}
if (content.includes('\n')) {
return '\n';
}
// Default to Unix line ending
return '\n';
}
/**
* Validate that a path is allowed by the access control list
* @param filePath - Path to validate (must be absolute)
*/
private validatePathAccess(filePath: string): void {
if (this.pathAccessControl && !this.pathAccessControl.isPathAllowed(filePath)) {
throw new Error(`Access denied: path not in allowlist: ${filePath}`);
}
}
/**
* Validate that a file path exists and is accessible
* @param filePath - Path to validate
*/
private validateFilePath(filePath: string): void {
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const stats = statSync(filePath);
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${filePath}`);
}
}
/**
* Validate that a file is writable
* @param filePath - Path to validate
*/
private validateWriteAccess(filePath: string): void {
try {
accessSync(filePath, constants.W_OK);
} catch {
throw new Error(`Permission denied: cannot write to ${filePath}`);
}
}
}