// Scoped filesystem. Every renderer-supplied path is resolved under // `app.getPath('userData')`. Post-normalisation we re-check the resolved // absolute path is still contained in the root; anything that breaks out // (via .., symlink, absolute path) is rejected. Binary payloads are // base64 on the wire because JSON IPC can't carry raw bytes cleanly. import { app, ipcMain } from 'electron'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { CHANNELS, type FsPath, type FsRenameArgs, type FsWriteArgs } from '../ipc-types'; function rootDir(): string { return app.getPath('userData'); } function resolveScoped(rel: FsPath): string { const root = rootDir(); if (path.isAbsolute(rel)) { throw new Error('fs-scoped: absolute path rejected'); } const normalised = path.normalize(rel); if (normalised.split(/[\\/]/).includes('..')) { throw new Error('fs-scoped: path traversal rejected'); } const abs = path.resolve(root, normalised); const withSep = root.endsWith(path.sep) ? root : root + path.sep; if (abs !== root && !abs.startsWith(withSep)) { throw new Error('fs-scoped: escaped scope'); } return abs; } export function register(): void { ipcMain.handle(CHANNELS.FS_APP_LOCAL_DATA_DIR, async (): Promise => rootDir()); ipcMain.handle(CHANNELS.FS_READ, async (_evt, rel: FsPath): Promise => { const abs = resolveScoped(rel); try { const buf = await fs.readFile(abs); return buf.toString('base64'); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; } }); ipcMain.handle(CHANNELS.FS_WRITE, async (_evt, args: FsWriteArgs): Promise => { const abs = resolveScoped(args.path); await fs.mkdir(path.dirname(abs), { recursive: true }); const buf = Buffer.from(args.dataBase64, 'base64'); await fs.writeFile(abs, buf); }); ipcMain.handle(CHANNELS.FS_EXISTS, async (_evt, rel: FsPath): Promise => { const abs = resolveScoped(rel); try { await fs.access(abs); return true; } catch { return false; } }); ipcMain.handle(CHANNELS.FS_MKDIR, async (_evt, rel: FsPath): Promise => { const abs = resolveScoped(rel); await fs.mkdir(abs, { recursive: true }); }); ipcMain.handle(CHANNELS.FS_RENAME, async (_evt, args: FsRenameArgs): Promise => { const from = resolveScoped(args.from); const to = resolveScoped(args.to); await fs.mkdir(path.dirname(to), { recursive: true }); await fs.rename(from, to); }); ipcMain.handle(CHANNELS.FS_REMOVE, async (_evt, rel: FsPath): Promise => { const abs = resolveScoped(rel); await fs.rm(abs, { recursive: true, force: true }); }); }