From f3ce17bf9e8dab7c3edfa813eeaf127487e29ea7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 13:26:53 -0500 Subject: [PATCH] refactor(desktop): extract filesystem IPC handlers from main.cjs into fs-ipc.cjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second main.cjs cluster peel (after git-ipc). The six hermes:fs:* handlers (readDir, gitRoot, reveal, rename, writeText, trash) move verbatim into electron/fs-ipc.cjs behind a registerFsIpc({ ipcMain, directoryExists, expandUserPath }) registrar — same injection pattern as registerGitIpc. Path hardening / read-dir / git-root come from their sibling modules directly; the two main-process path helpers are injected so the module stays side-effect free. Channel names are unchanged, so preload + renderer are untouched. main.cjs drops ~85 lines; the now-dead fs-read-dir / git-root requires in main.cjs are removed. Adds electron/fs-ipc.test.cjs asserting the hermes:fs:* surface by invariant. --- apps/desktop/electron/fs-ipc.cjs | 105 ++++++++++++++++++++++++++ apps/desktop/electron/fs-ipc.test.cjs | 49 ++++++++++++ apps/desktop/electron/main.cjs | 93 +---------------------- 3 files changed, 157 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/electron/fs-ipc.cjs create mode 100644 apps/desktop/electron/fs-ipc.test.cjs diff --git a/apps/desktop/electron/fs-ipc.cjs b/apps/desktop/electron/fs-ipc.cjs new file mode 100644 index 00000000000..75c0e657558 --- /dev/null +++ b/apps/desktop/electron/fs-ipc.cjs @@ -0,0 +1,105 @@ +'use strict' + +const { shell } = require('electron') +const fs = require('fs') +const path = require('path') + +const { readDirForIpc } = require('./fs-read-dir.cjs') +const { gitRootForIpc } = require('./git-root.cjs') +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +// Filesystem IPC: read-dir, git-root, reveal, rename, write-text, trash. Path +// hardening + `~` expansion + dir-existence checks live in the main process and +// are injected so this module stays side-effect free. +function registerFsIpc({ directoryExists, expandUserPath, ipcMain }) { + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) + + ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) + + // Reveal a path in the OS file manager (Finder / Explorer / Files). + ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } + }) + + // Rename a file/folder in place. The renderer passes the existing path + a new + // base name; the destination is resolved in the SAME parent dir so a rename can + // never move the item elsewhere or traverse out. Rejects on a name collision. + ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } + }) + + // Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path + // is hardened (resolveRequestedPathForIpc) and the parent must already exist — + // this never creates directory trees or escapes the allowed roots, and content + // is size-capped so it can't be abused as a bulk-write primitive. + ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } + }) + + // Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" + // default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. + ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true + }) +} + +module.exports = { registerFsIpc } diff --git a/apps/desktop/electron/fs-ipc.test.cjs b/apps/desktop/electron/fs-ipc.test.cjs new file mode 100644 index 00000000000..8b4536882c3 --- /dev/null +++ b/apps/desktop/electron/fs-ipc.test.cjs @@ -0,0 +1,49 @@ +'use strict' + +const assert = require('node:assert/strict') +const test = require('node:test') + +const { registerFsIpc } = require('./fs-ipc.cjs') + +function fakeIpcMain() { + const handlers = new Map() + + return { + handlers, + handle(channel, handler) { + assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`) + handlers.set(channel, handler) + } + } +} + +test('registerFsIpc wires only hermes:fs:* channels, each to a handler fn', () => { + const ipcMain = fakeIpcMain() + + registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p }) + + assert.ok(ipcMain.handlers.size >= 6, `expected the full fs surface, got ${ipcMain.handlers.size}`) + + for (const [channel, handler] of ipcMain.handlers) { + assert.match(channel, /^hermes:fs:/, `${channel} is not an fs channel`) + assert.equal(typeof handler, 'function', `${channel} should register a handler`) + } + + for (const channel of ['hermes:fs:readDir', 'hermes:fs:rename', 'hermes:fs:trash']) { + assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`) + } +}) + +test('rename rejects names that traverse out of the parent dir', async () => { + const ipcMain = fakeIpcMain() + + registerFsIpc({ ipcMain, directoryExists: () => true, expandUserPath: p => p }) + + for (const bad of ['..', '.', 'a/b', 'a\\b']) { + await assert.rejects( + () => ipcMain.handlers.get('hermes:fs:rename')({}, '/tmp/x', bad), + /Invalid rename/, + `"${bad}" should be rejected` + ) + } +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index e5dd11120e1..b394e115c47 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -46,7 +46,6 @@ const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-e const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs') const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs') -const { readDirForIpc } = require('./fs-read-dir.cjs') const { readLiveUpdateMarker } = require('./update-marker.cjs') const { resolveUnpackedRelease, @@ -57,8 +56,8 @@ const { collectRelaunchEnv, buildRelaunchScript } = require('./update-relaunch.cjs') -const { gitRootForIpc } = require('./git-root.cjs') const { registerGitIpc } = require('./git-ipc.cjs') +const { registerFsIpc } = require('./fs-ipc.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs') const { runRebuildWithRetry } = require('./update-rebuild.cjs') @@ -6898,94 +6897,8 @@ function disposeTerminalSession(id) { return true } -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) - -// Reveal a path in the OS file manager (Finder / Explorer / Files). -ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - return false - } - - try { - shell.showItemInFolder(target) - - return true - } catch { - return false - } -}) - -// Rename a file/folder in place. The renderer passes the existing path + a new -// base name; the destination is resolved in the SAME parent dir so a rename can -// never move the item elsewhere or traverse out. Rejects on a name collision. -ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { - const src = String(targetPath || '').trim() - const name = String(newName || '').trim() - - if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - throw new Error('Invalid rename') - } - - const dst = path.join(path.dirname(src), name) - - if (dst === src) { - return { path: dst } - } - - if (fs.existsSync(dst)) { - throw new Error(`"${name}" already exists`) - } - - await fs.promises.rename(src, dst) - - return { path: dst } -}) - -// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path -// is hardened (resolveRequestedPathForIpc) and the parent must already exist — -// this never creates directory trees or escapes the allowed roots, and content -// is size-capped so it can't be abused as a bulk-write primitive. -ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { - const raw = String(filePath || '').trim() - - if (!raw) { - throw new Error('Invalid path') - } - - const text = String(content ?? '') - - if (text.length > 1_000_000) { - throw new Error('Content too large') - } - - const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) - - if (!directoryExists(path.dirname(resolved))) { - throw new Error('Parent directory does not exist') - } - - await fs.promises.writeFile(resolved, text, 'utf8') - - return { path: resolved } -}) - -// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" -// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. -ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - throw new Error('Invalid delete') - } - - await shell.trashItem(target) - - return true -}) +// Filesystem IPC lives in fs-ipc.cjs; main-process path helpers are injected. +registerFsIpc({ ipcMain, directoryExists, expandUserPath }) // Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers // stay here (Windows PATH discovery) and are injected into the registrar.