Project Files
src / fileUtilities.ts
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { sanitizeFilePath } from "./securityEnhanced";
export interface FileStats {
size: number;
modified: string;
isDirectory: boolean;
isFile: boolean;
permissions: string;
}
export interface FileListEntry {
name: string;
path: string;
size: number;
modified: string;
isDirectory: boolean;
}
export interface FileWriteResult {
success: boolean;
path: string;
size: number;
error?: string;
}
export interface FileDeleteResult {
success: boolean;
path: string;
error?: string;
}
export interface DirectoryCreateResult {
success: boolean;
path: string;
error?: string;
}
export interface DirectoryDeleteResult {
success: boolean;
path: string;
error?: string;
}
export interface DirectoryListResult {
success: boolean;
path: string;
entries: FileListEntry[];
error?: string;
}
export interface FileDiffResult {
success: boolean;
diff: string;
error?: string;
}
// --- File reading ---
export function readFile(filePath: string): { success: boolean; content: string; error?: string } {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return { success: false, content: "", error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
try {
const content = fs.readFileSync(resolvedPath, "utf-8");
return { success: true, content, path: resolvedPath };
} catch (err: any) {
return { success: false, content: "", error: err.message };
}
}
export function readFileBytes(filePath: string): { success: boolean; content: Buffer; error?: string } {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return { success: false, content: Buffer.alloc(0), error: sanitised.error };
}
try {
const content = fs.readFileSync(sanitised.sanitized!);
return { success: true, content };
} catch (err: any) {
return { success: false, content: Buffer.alloc(0), error: err.message };
}
}
// --- File writing ---
export function writeFile(filePath: string, content: string, options?: { overwrite?: boolean; createDirs?: boolean }): FileWriteResult {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return { success: false, path: filePath, size: 0, error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
const { overwrite = true, createDirs = true } = options || {};
try {
if (!overwrite && fs.existsSync(resolvedPath)) {
return { success: false, path: resolvedPath, size: 0, error: "File already exists and overwrite is disabled" };
}
if (createDirs) {
const dir = path.dirname(resolvedPath);
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(resolvedPath, content, "utf-8");
const stats = fs.statSync(resolvedPath);
return { success: true, path: resolvedPath, size: stats.size };
} catch (err: any) {
return { success: false, path: resolvedPath, size: 0, error: err.message };
}
}
// --- File deletion ---
export function deleteFile(filePath: string, options?: { recursive?: boolean; missingOk?: boolean }): FileDeleteResult {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return { success: false, path: filePath, error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
const { recursive = false, missingOk = false } = options || {};
if (!fs.existsSync(resolvedPath)) {
return { success: missingOk, path: resolvedPath };
}
try {
if (fs.statSync(resolvedPath).isDirectory()) {
fs.rmSync(resolvedPath, { recursive });
} else {
fs.unlinkSync(resolvedPath);
}
return { success: true, path: resolvedPath };
} catch (err: any) {
return { success: false, path: resolvedPath, error: err.message };
}
}
// --- Directory operations ---
export function createDirectory(dirPath: string, options?: { recursive?: boolean }): DirectoryCreateResult {
const sanitised = sanitizeFilePath(dirPath);
if (!sanitised.valid) {
return { success: false, path: dirPath, error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
const { recursive = true } = options || {};
try {
fs.mkdirSync(resolvedPath, { recursive });
return { success: true, path: resolvedPath };
} catch (err: any) {
return { success: false, path: resolvedPath, error: err.message };
}
}
export function deleteDirectory(dirPath: string, options?: { recursive?: boolean; missingOk?: boolean }): DirectoryDeleteResult {
const sanitised = sanitizeFilePath(dirPath);
if (!sanitised.valid) {
return { success: false, path: dirPath, error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
const { recursive = true, missingOk = false } = options || {};
if (!fs.existsSync(resolvedPath)) {
return { success: missingOk, path: resolvedPath };
}
try {
fs.rmSync(resolvedPath, { recursive });
return { success: true, path: resolvedPath };
} catch (err: any) {
return { success: false, path: resolvedPath, error: err.message };
}
}
export function listDirectory(dirPath: string, options?: { recursive?: boolean; showHidden?: boolean }): DirectoryListResult {
const sanitised = sanitizeFilePath(dirPath);
if (!sanitised.valid) {
return { success: false, path: dirPath, entries: [], error: sanitised.error };
}
const resolvedPath = sanitised.sanitized!;
const { recursive = false, showHidden = false } = options || {};
try {
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true });
const result: FileListEntry[] = [];
const walk = (dir: string, prefix: string = ""): void => {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
if (!showHidden && item.name.startsWith(".")) continue;
const fullPath = path.join(dir, item.name);
const stat = fs.statSync(fullPath);
result.push({
name: item.name,
path: fullPath,
size: stat.isFile() ? stat.size : 0,
modified: stat.mtime.toISOString(),
isDirectory: stat.isDirectory(),
});
if (recursive && item.isDirectory()) {
walk(fullPath, path.join(prefix, item.name));
}
}
};
walk(resolvedPath);
return { success: true, path: resolvedPath, entries };
} catch (err: any) {
return { success: false, path: resolvedPath, entries: [], error: err.message };
}
}
// --- File stats ---
export function getFileStats(filePath: string): { success: boolean; stats: FileStats; error?: string } {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return { success: false, stats: { size: 0, modified: "", isDirectory: false, isFile: false, permissions: "" }, error: sanitised.error };
}
try {
const stat = fs.statSync(sanitised.sanitized!);
return {
success: true,
stats: {
size: stat.size,
modified: stat.mtime.toISOString(),
isDirectory: stat.isDirectory(),
isFile: stat.isFile(),
permissions: stat.mode.toString(8).slice(-4),
},
};
} catch (err: any) {
return { success: false, stats: { size: 0, modified: "", isDirectory: false, isFile: false, permissions: "" }, error: err.message };
}
}
// --- File diff (unified format) ---
export function fileDiff(filePath1: string, filePath2: string): FileDiffResult {
const s1 = sanitizeFilePath(filePath1);
const s2 = sanitizeFilePath(filePath2);
if (!s1.valid || !s2.valid) {
return { success: false, diff: "", error: "Invalid file paths" };
}
try {
const content1 = fs.readFileSync(s1.sanitized!, "utf-8");
const content2 = fs.readFileSync(s2.sanitized!, "utf-8");
const lines1 = content1.split("\n");
const lines2 = content2.split("\n");
// Use LCS-based diff for proper unified output
const { added, removed } = computeLcs(lines1, lines2);
if (added.length === 0 && removed.length === 0) {
return { success: true, diff: "(Files are identical)" };
}
const diffLines: string[] = [];
let hunkCounter = 0;
let i = 0;
let j = 0;
while (i < lines1.length || j < lines2.length) {
// Find next hunk start
while (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
i++;
j++;
}
if (i >= lines1.length && j >= lines2.length) break;
// Collect context before
const ctxStart = Math.max(0, i - 3);
const ctxLines: string[] = [];
for (let k = ctxStart; k < i; k++) {
ctxLines.push(` ${lines1[k]}`);
}
// Collect removed lines
const removedLines: string[] = [];
while (i < lines1.length && j < lines2.length && lines1[i] !== lines2[j]) {
removedLines.push(`-${lines1[i]}`);
i++;
}
// Collect added lines
const addedLines: string[] = [];
while (j < lines2.length && i >= lines1.length || (j < lines2.length && i < lines1.length && lines1[i] !== lines2[j])) {
addedLines.push(`+${lines2[j]}`);
j++;
}
// Collect context after
const ctxEnd = Math.min(lines1.length, i + 3);
const ctxAfter: string[] = [];
for (let k = i; k < ctxEnd; k++) {
ctxAfter.push(` ${lines1[k]}`);
}
const startLine = ctxStart + 1;
const count = ctxLines.length + removedLines.length + addedLines.length + ctxAfter.length;
diffLines.push(`@@ -${startLine},${count} +${startLine},${count} @@`);
diffLines.push(...ctxLines);
diffLines.push(...removedLines);
diffLines.push(...addedLines);
diffLines.push(...ctxAfter);
diffLines.push("");
hunkCounter++;
if (hunkCounter > 100) {
diffLines.push("\\ No more hunks (truncated)");
break;
}
}
return { success: true, diff: diffLines.join("\n") };
} catch (err: any) {
return { success: false, diff: "", error: err.message };
}
}
/** Compute LCS-based diff between two line arrays. */
function computeLcs(lines1: string[], lines2: string[]): { added: string[]; removed: string[] } {
const m = lines1.length;
const n = lines2.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (lines1[i - 1] === lines2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
const added: string[] = [];
const removed: string[] = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (lines1[i - 1] === lines2[j - 1]) {
i--;
j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
removed.push(lines1[i - 1]);
i--;
} else {
added.push(lines2[j - 1]);
j--;
}
}
while (i > 0) {
removed.push(lines1[i - 1]);
i--;
}
while (j > 0) {
added.push(lines2[j - 1]);
j--;
}
added.reverse();
removed.reverse();
return { added, removed };
}
// --- File checksum ---
export function fileChecksum(filePath: string, algorithm: "sha256" | "md5" = "sha256"): Promise<{ success: boolean; checksum: string; error?: string }> {
const sanitised = sanitizeFilePath(filePath);
if (!sanitised.valid) {
return Promise.resolve({ success: false, checksum: "", error: sanitised.error });
}
return new Promise((resolve) => {
try {
const hash = crypto.createHash(algorithm);
const stream = fs.createReadStream(sanitised.sanitized!);
stream.on("data", (chunk: Buffer) => hash.update(chunk));
stream.on("end", () => resolve({ success: true, checksum: hash.digest("hex") }));
stream.on("error", (err: NodeJS.ErrnoException) => resolve({ success: false, checksum: "", error: err.message }));
} catch (err: any) {
resolve({ success: false, checksum: "", error: err.message });
}
});
}