refactor(ipc): harden channels and unify IPC contracts
This commit is contained in:
@@ -1,67 +1,87 @@
|
||||
import { ipcMain, shell } from 'electron'
|
||||
import { app, ipcMain, shell } from 'electron'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('FileHandler')
|
||||
|
||||
function getAllowedRoots(): string[] {
|
||||
return [path.resolve(app.getAppPath()), path.resolve(app.getPath('userData'))]
|
||||
}
|
||||
|
||||
export function isPathWithinAllowedRoots(inputPath: string, roots: string[]): boolean {
|
||||
const normalized = path.resolve(inputPath)
|
||||
return roots.some((root) => {
|
||||
const rel = path.relative(root, normalized)
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAndValidatePath(inputPath: string): string {
|
||||
const normalized = path.resolve(inputPath)
|
||||
const isAllowed = isPathWithinAllowedRoots(normalized, getAllowedRoots())
|
||||
if (!isAllowed) {
|
||||
throw new ValidationError('Path is outside allowed roots', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function registerFileHandlers(): void {
|
||||
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
|
||||
try {
|
||||
log.debug('Reading file', { filePath })
|
||||
return await fs.readFile(filePath, 'utf-8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read file'
|
||||
log.error('Failed to read file', { filePath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event, filePath: string): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Reading file', { filePath: safePath })
|
||||
return await fs.readFile(safePath, 'utf-8')
|
||||
}, 'file:read')
|
||||
})
|
||||
|
||||
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
|
||||
try {
|
||||
log.debug('Writing file', { filePath })
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write file'
|
||||
log.error('Failed to write file', { filePath, error: message })
|
||||
throw new Error(message)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_WRITE,
|
||||
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Writing file', { filePath: safePath })
|
||||
const dir = path.dirname(safePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(safePath, content, 'utf-8')
|
||||
}, 'file:write')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_EXISTS, async (_event, filePath: string): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
try {
|
||||
await fs.access(safePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'file:exists')
|
||||
})
|
||||
|
||||
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
|
||||
try {
|
||||
log.debug('Listing directory', { dirPath })
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_LIST, async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(dirPath)
|
||||
log.debug('Listing directory', { dirPath: safePath })
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list directory'
|
||||
log.error('Failed to list directory', { dirPath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
}, 'file:list')
|
||||
})
|
||||
|
||||
ipcMain.handle('file:openPath', async (_event, filePath: string): Promise<void> => {
|
||||
try {
|
||||
log.debug('Opening path in explorer', { filePath })
|
||||
await shell.openPath(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to open path'
|
||||
log.error('Failed to open path', { filePath, error: message })
|
||||
throw new Error(message)
|
||||
}
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_OPEN_PATH, async (_event, filePath: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Opening path in explorer', { filePath: safePath })
|
||||
await fs.access(safePath)
|
||||
await shell.openPath(safePath)
|
||||
}, 'file:openPath')
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user