refactor(desktop): extract filesystem IPC handlers from main.cjs into fs-ipc.cjs

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.
This commit is contained in:
Brooklyn Nicholson 2026-06-30 13:26:53 -05:00
parent b29bb6ef9d
commit f3ce17bf9e
3 changed files with 157 additions and 90 deletions

View file

@ -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 }

View file

@ -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`
)
}
})

View file

@ -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.