diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.cjs new file mode 100644 index 00000000000..52d182ad567 --- /dev/null +++ b/apps/desktop/electron/fs-read-dir.cjs @@ -0,0 +1,109 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { resolveDirectoryForIpc } = require('./hardening.cjs') + +const FS_READDIR_STAT_CONCURRENCY = 16 + +// Always-hidden noise (covers non-git projects too; gitignore catches many of +// these, but the project tree should keep the same hygiene without one). +const FS_READDIR_HIDDEN = new Set([ + '.git', + '.hg', + '.svn', + '.cache', + '.next', + '.turbo', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', + 'venv' +]) + +function direntIsDirectory(dirent) { + return typeof dirent.isDirectory === 'function' && dirent.isDirectory() +} + +function direntIsFile(dirent) { + return typeof dirent.isFile === 'function' && dirent.isFile() +} + +function direntIsSymbolicLink(dirent) { + return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink() +} + +function shouldStatDirent(dirent) { + if (direntIsDirectory(dirent)) return false + + return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) +} + +async function entryForDirent(dirent, resolved, fsImpl) { + const fullPath = path.join(resolved, dirent.name) + let isDirectory = direntIsDirectory(dirent) + + if (!isDirectory && shouldStatDirent(dirent)) { + try { + isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory() + } catch { + isDirectory = false + } + } + + return { name: dirent.name, path: fullPath, isDirectory } +} + +async function mapWithStatConcurrency(items, mapper) { + const results = new Array(items.length) + let nextIndex = 0 + + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(items[index]) + } + } + + const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) + const workers = Array.from({ length: workerCount }, () => runWorker()) + await Promise.all(workers) + + return results +} + +async function readDirForIpc(dirPath, options = {}) { + const fsImpl = options.fs || fs + let resolved + + try { + ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, { + fs: fsImpl, + purpose: 'Directory read' + })) + } catch (error) { + return { entries: [], error: error?.code || 'read-error' } + } + + try { + const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true }) + const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name)) + const entries = await mapWithStatConcurrency(visibleDirents, dirent => + entryForDirent(dirent, resolved, fsImpl) + ) + + entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) + + return { entries } + } catch (error) { + return { entries: [], error: error?.code || 'read-error' } + } +} + +module.exports = { + readDirForIpc +} diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.cjs new file mode 100644 index 00000000000..42e80af3489 --- /dev/null +++ b/apps/desktop/electron/fs-read-dir.test.cjs @@ -0,0 +1,364 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') +const { pathToFileURL } = require('node:url') + +const { readDirForIpc } = require('./fs-read-dir.cjs') + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-')) +} + +function fakeDirent(name, flags = {}) { + return { + name, + isDirectory: () => Boolean(flags.directory), + isFile: () => Boolean(flags.file), + isSymbolicLink: () => Boolean(flags.symlink) + } +} + +test('readDirForIpc hides noisy directories and files from the project tree', async () => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'node_modules')) + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'target'), 'hidden file') + fs.writeFileSync(path.join(root, 'README.md'), 'visible file') + + const result = await readDirForIpc(root) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['src', 'README.md'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => { + const dirRoot = mkTmpDir() + const fileRoot = mkTmpDir() + + try { + fs.mkdirSync(path.join(dirRoot, 'node_modules')) + fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible') + fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file') + fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible') + + assert.deepEqual( + (await readDirForIpc(dirRoot)).entries.map(entry => entry.name), + ['visible.txt'] + ) + assert.deepEqual( + (await readDirForIpc(fileRoot)).entries.map(entry => entry.name), + ['visible.txt'] + ) + } finally { + fs.rmSync(dirRoot, { recursive: true, force: true }) + fs.rmSync(fileRoot, { recursive: true, force: true }) + } +}) + +test('readDirForIpc returns directories before files and sorts by name within groups', async () => { + const root = mkTmpDir() + + try { + fs.writeFileSync(path.join(root, 'z.txt'), 'z') + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'a.txt'), 'a') + fs.mkdirSync(path.join(root, 'lib')) + + const result = await readDirForIpc(root) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['lib', 'src', 'a.txt', 'z.txt'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc accepts file URLs for directories', async () => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'README.md'), 'visible file') + + const result = await readDirForIpc(pathToFileURL(root).toString()) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['src', 'README.md'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc returns invalid-path for blank or non-string input', async () => { + let readdirCalls = 0 + const fsImpl = { + promises: { + readdir: async () => { + readdirCalls += 1 + return [] + } + } + } + + assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.equal(readdirCalls, 0) +}) + +test('readDirForIpc rejects Windows device paths before readdir', async () => { + let readdirCalls = 0 + const fsImpl = { + promises: { + readdir: async () => { + readdirCalls += 1 + return [] + } + } + } + + assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), { + entries: [], + error: 'device-path' + }) + assert.equal(readdirCalls, 0) +}) + +test('readDirForIpc returns filesystem error codes instead of throwing', async () => { + const root = mkTmpDir() + + try { + const result = await readDirForIpc(path.join(root, 'missing')) + + assert.deepEqual(result, { entries: [], error: 'ENOENT' }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc marks a symlink to a directory as a directory', async t => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'actual-dir')) + + try { + fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`symlink creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(root) + const linked = result.entries.find(entry => entry.name === 'linked-dir') + + assert.equal(result.error, undefined) + assert.equal(linked?.isDirectory, true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc marks a Windows junction to a directory as a directory', async t => { + if (process.platform !== 'win32') { + t.skip('junctions are a Windows-specific symlink type') + + return + } + + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'actual-dir')) + + try { + fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`junction creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(root) + const junction = result.entries.find(entry => entry.name === 'junction-dir') + + assert.equal(result.error, undefined) + assert.equal(junction?.isDirectory, true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => { + const root = mkTmpDir() + const outside = mkTmpDir() + + try { + fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok') + + const linkPath = path.join(root, 'outside-link') + try { + fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(linkPath) + + assert.equal(result.error, undefined) + assert.deepEqual(result.entries, [ + { name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false } + ]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(outside, { recursive: true, force: true }) + } +}) + +test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => { + const input = path.join('virtual-root') + const resolved = path.resolve(input) + const statCalls = [] + const fsImpl = { + promises: { + readdir: async () => [ + fakeDirent('unknown-entry'), + fakeDirent('linked-dir', { symlink: true }), + fakeDirent('broken-link', { symlink: true }), + fakeDirent('plain.txt', { file: true }) + ], + stat: async fullPath => { + if (fullPath === resolved) { + return { isDirectory: () => true } + } + + statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { + return { isDirectory: () => true } + } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) + } + } + } + + const result = await readDirForIpc(input, { fs: fsImpl }) + + assert.equal(result.error, undefined) + assert.deepEqual( + statCalls.sort(), + [path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort() + ) + assert.deepEqual(result.entries, [ + { name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true }, + { name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false }, + { name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false }, + { name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false } + ]) +}) + +test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => { + const input = path.join('virtual-root') + const resolved = path.resolve(input) + const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`) + const failedName = 'entry-100' + const directoryNames = new Set(names.filter((_, index) => index % 10 === 4)) + const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName)) + const statCalls = [] + let active = 0 + let peak = 0 + let releaseStats + let markFirstStatStarted + const statsReleased = new Promise(resolve => { + releaseStats = resolve + }) + const firstStatStarted = new Promise(resolve => { + markFirstStatStarted = resolve + }) + const fsImpl = { + promises: { + readdir: async () => [ + fakeDirent('node_modules', { symlink: true }), + ...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 })) + ], + stat: async fullPath => { + if (fullPath === resolved) { + return { isDirectory: () => true } + } + + statCalls.push(fullPath) + active += 1 + peak = Math.max(peak, active) + markFirstStatStarted() + await statsReleased + active -= 1 + + const name = path.basename(fullPath) + if (name === failedName) { + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) + } + + return { isDirectory: () => successfulDirectoryNames.has(name) } + } + } + } + + const resultPromise = readDirForIpc(input, { fs: fsImpl }) + await firstStatStarted + await new Promise(resolve => setImmediate(resolve)) + releaseStats() + const result = await resultPromise + + const expectedNames = [ + ...names.filter(name => successfulDirectoryNames.has(name)).sort(), + ...names.filter(name => !successfulDirectoryNames.has(name)).sort() + ] + + assert.equal(result.error, undefined) + assert.equal(result.entries.length, names.length) + assert.equal(statCalls.length, names.length) + assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false) + assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`) + assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`) + assert.deepEqual( + result.entries.map(entry => entry.name), + expectedNames + ) + assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false) + assert.equal( + result.entries.filter(entry => entry.isDirectory).length, + successfulDirectoryNames.size + ) +}) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.cjs new file mode 100644 index 00000000000..593d3531ebc --- /dev/null +++ b/apps/desktop/electron/git-root.cjs @@ -0,0 +1,54 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function findGitRoot(start, fsImpl = fs) { + let dir = start + + for (let i = 0; i < 50; i += 1) { + try { + if (fsImpl.existsSync(path.join(dir, '.git'))) { + return dir + } + } catch { + return null + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return null + } + + dir = parent + } + + return null +} + +async function gitRootForIpc(startPath, options = {}) { + const fsImpl = options.fs || fs + let resolved + + try { + resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' }) + } catch { + return null + } + + try { + const stat = await fsImpl.promises.stat(resolved) + const start = stat.isDirectory() ? resolved : path.dirname(resolved) + + return findGitRoot(start, fsImpl) + } catch { + return findGitRoot(resolved, fsImpl) + } +} + +module.exports = { + findGitRoot, + gitRootForIpc +} diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.cjs new file mode 100644 index 00000000000..ba649b259f3 --- /dev/null +++ b/apps/desktop/electron/git-root.test.cjs @@ -0,0 +1,40 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') +const { pathToFileURL } = require('node:url') + +const { gitRootForIpc } = require('./git-root.cjs') + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) +} + +test('gitRootForIpc returns null for invalid and device paths', async () => { + assert.equal(await gitRootForIpc(''), null) + assert.equal(await gitRootForIpc(' '), null) + assert.equal(await gitRootForIpc(null), null) + assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) + assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) +}) + +test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => { + const root = mkTmpDir() + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + + const gitDir = path.join(root, '.git') + const srcDir = path.join(root, 'src') + const filePath = path.join(srcDir, 'index.ts') + fs.mkdirSync(gitDir) + fs.mkdirSync(srcDir) + fs.writeFileSync(filePath, 'export {}\n', 'utf8') + + assert.equal(await gitRootForIpc(root), root) + assert.equal(await gitRootForIpc(srcDir), root) + assert.equal(await gitRootForIpc(filePath), root) + assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) + assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) +}) diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs index 4ffdea051b5..812dc3f77c7 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.cjs @@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) { return null } -function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') { - const raw = String(filePath || '').trim() +function ipcPathError(code, message) { + const error = new Error(message) + error.code = code + return error +} + +function rejectUnsafePathSyntax(filePath, purpose = 'File read') { + if (typeof filePath !== 'string') { + throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) + } + + const raw = filePath.trim() if (!raw) { - throw new Error(`${purpose} failed: file path is required.`) + throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) } if (raw.includes('\0')) { - throw new Error(`${purpose} failed: file path is invalid.`) + throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`) } + const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( + normalized.startsWith('//?/') || + normalized.startsWith('//./') || + normalized.startsWith('globalroot/device/') || + normalized.includes('/globalroot/device/') + ) { + throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`) + } + + return raw +} + +function resolveRequestedPathForIpc(filePath, options = {}) { + const purpose = String(options.purpose || 'File read') + const raw = rejectUnsafePathSyntax(filePath, purpose) + if (/^file:/i.test(raw)) { + let resolvedPath try { - return fileURLToPath(raw) + const parsed = new URL(raw) + if (parsed.protocol !== 'file:') { + throw new Error('not a file URL') + } + resolvedPath = fileURLToPath(parsed) } catch { - throw new Error(`${purpose} failed: file URL is invalid.`) + throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`) } + + rejectUnsafePathSyntax(resolvedPath, purpose) + return path.resolve(resolvedPath) } - const resolvedBase = path.resolve(String(baseDir || process.cwd())) - return path.resolve(resolvedBase, raw) + const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd() + const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose) + const resolvedBase = path.resolve(safeBaseInput) + rejectUnsafePathSyntax(resolvedBase, purpose) + const resolvedPath = path.resolve(resolvedBase, raw) + rejectUnsafePathSyntax(resolvedPath, purpose) + + return resolvedPath +} + +async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { + try { + return await fsImpl.promises.stat(resolvedPath) + } catch (error) { + const code = error && typeof error === 'object' ? error.code : '' + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) + } + throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +async function realpathForIpc(fsImpl, resolvedPath, purpose) { + if (typeof fsImpl.promises.realpath !== 'function') { + return resolvedPath + } + + try { + const realPath = await fsImpl.promises.realpath(resolvedPath) + rejectUnsafePathSyntax(realPath, purpose) + return realPath + } catch (error) { + const code = error && typeof error === 'object' ? error.code : '' + throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function rejectSensitiveFilePath(filePath, purpose) { + const blockReason = sensitiveFileBlockReason(filePath) + if (blockReason) { + throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`) + } +} + +async function resolveDirectoryForIpc(dirPath, options = {}) { + const purpose = String(options.purpose || 'Directory read') + const fsImpl = options.fs || fs + const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) + const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory') + + if (!stat.isDirectory()) { + throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`) + } + + const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + + return { realPath, resolvedPath, stat } } async function resolveReadableFileForIpc(filePath, options = {}) { const purpose = String(options.purpose || 'File read') - const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose) + const fsImpl = options.fs || fs + const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose }) if (options.blockSensitive !== false) { - const blockReason = sensitiveFileBlockReason(resolvedPath) - if (blockReason) { - throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`) - } + rejectSensitiveFilePath(resolvedPath, purpose) } - let stat - try { - stat = await fs.promises.stat(resolvedPath) - } catch (error) { - const code = error && typeof error === 'object' ? error.code : '' - if (code === 'ENOENT' || code === 'ENOTDIR') { - throw new Error(`${purpose} failed: file does not exist.`) - } - throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) - } + const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file') if (stat.isDirectory()) { - throw new Error(`${purpose} failed: path points to a directory.`) + throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`) } if (!stat.isFile()) { - throw new Error(`${purpose} failed: only regular files can be read.`) + throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`) + } + + const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + if (options.blockSensitive !== false) { + rejectSensitiveFilePath(realPath, purpose) } const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null if (maxBytes && stat.size > maxBytes) { - throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) + throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } try { - await fs.promises.access(resolvedPath, fs.constants.R_OK) + await fsImpl.promises.access(resolvedPath, fs.constants.R_OK) } catch { - throw new Error(`${purpose} failed: file is not readable.`) + throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`) } - return { resolvedPath, stat } + return { realPath, resolvedPath, stat } } module.exports = { @@ -178,7 +262,10 @@ module.exports = { DEFAULT_FETCH_TIMEOUT_MS, TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, + rejectUnsafePathSyntax, + resolveDirectoryForIpc, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs, sensitiveFileBlockReason } diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs index 865da8fe797..a52ee27c830 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.cjs @@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url') const { DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, + resolveDirectoryForIpc, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs, sensitiveFileBlockReason } = require('./hardening.cjs') +async function rejectsWithCode(promise, code) { + await assert.rejects(promise, error => { + assert.equal(error?.code, code) + return true + }) +} + test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) @@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) }) +test('path helpers reject blank non-string NUL and Windows device syntax', async () => { + await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') + + const devicePaths = [ + '\\\\?\\C:\\secret.txt', + '\\\\.\\C:\\secret.txt', + '\\\\?\\UNC\\server\\share\\secret.txt', + 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' + ] + + for (const devicePath of devicePaths) { + assert.throws( + () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), + error => { + assert.equal(error?.code, 'device-path') + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') + } + + assert.throws( + () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), + error => { + assert.equal(error?.code, 'invalid-path') + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') +}) + +test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { + const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') + + assert.equal( + resolveRequestedPathForIpc('notes.txt', { + baseDir: ` ${baseDir} `, + purpose: 'File preview' + }), + path.resolve(baseDir, 'notes.txt') + ) +}) + test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) @@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', }) assert.equal(fromFileUrl.resolvedPath, textPath) + const spacedPath = path.join(tempDir, 'notes with spaces.txt') + fs.writeFileSync(spacedPath, 'space ok', 'utf8') + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { + purpose: 'File preview' + }) + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) + await assert.rejects( resolveReadableFileForIpc('missing.txt', { baseDir: tempDir, @@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', }) assert.equal(envTemplate.resolvedPath, envTemplatePath) }) + +test('resolveReadableFileForIpc blocks common sensitive files', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const sshDir = path.join(tempDir, '.ssh') + fs.mkdirSync(sshDir) + + const blockedFiles = [ + path.join(tempDir, '.env'), + path.join(tempDir, '.npmrc'), + path.join(sshDir, 'id_ed25519'), + path.join(tempDir, 'cert.pem'), + path.join(tempDir, 'cert.p12'), + path.join(tempDir, 'cert.pfx') + ] + + for (const filePath of blockedFiles) { + fs.writeFileSync(filePath, 'secret', 'utf8') + await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') + } + + const allowed = path.join(tempDir, '.env.example') + fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') + assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) +}) + +test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const envPath = path.join(tempDir, '.env') + const linkPath = path.join(tempDir, 'safe-name.txt') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + + try { + fs.symlinkSync(envPath, linkPath, 'file') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`symlink creation is not permitted on this platform (${error.code})`) + return + } + throw error + } + + await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') +}) + +test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const directory = path.join(tempDir, 'project') + const filePath = path.join(tempDir, 'file.txt') + fs.mkdirSync(directory) + fs.writeFileSync(filePath, 'not a directory', 'utf8') + + const resolved = await resolveDirectoryForIpc(directory) + assert.equal(resolved.resolvedPath, directory) + assert.equal(resolved.stat.isDirectory(), true) + + await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') + await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') + await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') +}) + +test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const directory = path.join(tempDir, 'actual-project') + const linkPath = path.join(tempDir, 'linked-project') + fs.mkdirSync(directory) + + try { + fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) + return + } + throw error + } + + const resolved = await resolveDirectoryForIpc(linkPath) + assert.equal(resolved.resolvedPath, linkPath) + assert.equal(resolved.stat.isDirectory(), true) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5af4b5605ce..9abfc216e56 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -22,7 +22,7 @@ const http = require('node:http') const https = require('node:https') const net = require('node:net') const path = require('node:path') -const { fileURLToPath, pathToFileURL } = require('node:url') +const { pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') @@ -31,6 +31,8 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') +const { readDirForIpc } = require('./fs-read-dir.cjs') +const { gitRootForIpc } = require('./git-root.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote @@ -65,6 +67,7 @@ const { TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret: encryptDesktopSecretStrict, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs } = require('./hardening.cjs') @@ -730,7 +733,7 @@ function openExternalUrl(rawUrl) { if (parsed.protocol === 'file:') { let localPath try { - localPath = fileURLToPath(parsed.toString()) + localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { return false } @@ -2878,10 +2881,10 @@ async function resourceBufferFromUrl(rawUrl) { const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') return { buffer, mimeType } } - if (rawUrl.startsWith('file:')) { - const filePath = fileURLToPath(rawUrl) - const buffer = await fs.promises.readFile(filePath) - return { buffer, mimeType: mimeTypeForPath(filePath) } + if (/^file:/i.test(rawUrl)) { + const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) + const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) @@ -2959,11 +2962,13 @@ function expandUserPath(filePath) { return value } -function previewFileTarget(rawTarget, baseDir) { +async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() - const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw)) - let resolved = filePath + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { + baseDir: base, + purpose: 'Preview target' + }) if (directoryExists(resolved)) { resolved = path.join(resolved, 'index.html') @@ -2974,6 +2979,8 @@ function previewFileTarget(rawTarget, baseDir) { return null } + ;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' })) + const mimeType = mimeTypeForPath(resolved) const metadata = previewFileMetadata(resolved, mimeType) const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext) @@ -3019,7 +3026,7 @@ function previewUrlTarget(rawTarget) { } } -function normalizePreviewTarget(rawTarget, baseDir) { +async function normalizePreviewTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() if (!raw) { @@ -3031,20 +3038,15 @@ function normalizePreviewTarget(rawTarget, baseDir) { return previewUrlTarget(raw) } - return previewFileTarget(raw, baseDir) + return await previewFileTarget(raw, baseDir) } catch { return null } } -function filePathFromPreviewUrl(rawUrl) { - const filePath = fileURLToPath(String(rawUrl || '')) - - if (!fileExists(filePath)) { - throw new Error('Preview file is not readable') - } - - return filePath +async function filePathFromPreviewUrl(rawUrl) { + const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { @@ -3054,8 +3056,8 @@ function sendPreviewFileChanged(payload) { webContents.send('hermes:preview-file-changed', payload) } -function watchPreviewFile(rawUrl) { - const filePath = filePathFromPreviewUrl(rawUrl) +async function watchPreviewFile(rawUrl) { + const filePath = await filePathFromPreviewUrl(rawUrl) const watchDir = path.dirname(filePath) const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') @@ -5587,48 +5589,6 @@ ipcMain.handle('hermes:logs:reveal', async () => { ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) })) -// Always-hidden noise (covers non-git projects too — gitignore would catch -// these anyway when present, but we want the same hygiene without one). -const FS_READDIR_HIDDEN = new Set([ - '.git', - '.hg', - '.svn', - '.cache', - '.next', - '.turbo', - '.venv', - '__pycache__', - 'build', - 'dist', - 'node_modules', - 'target', - 'venv' -]) - -function findGitRoot(start) { - let dir = start - - for (let i = 0; i < 50; i += 1) { - try { - if (fs.existsSync(path.join(dir, '.git'))) { - return dir - } - } catch { - return null - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return null - } - - dir = parent - } - - return null -} - function isExecutableFile(filePath) { if (!filePath || !path.isAbsolute(filePath)) { return false @@ -5811,46 +5771,9 @@ function disposeTerminalSession(id) { return true } -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { - const resolved = path.resolve(String(dirPath || '')) +ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - if (!resolved) { - return { entries: [], error: 'invalid-path' } - } - - try { - const dirents = await fs.promises.readdir(resolved, { withFileTypes: true }) - - const entries = dirents - .filter(d => { - if (FS_READDIR_HIDDEN.has(d.name)) { - return false - } - - return true - }) - .map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() })) - .sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) - - return { entries } - } catch (error) { - return { entries: [], error: error?.code || 'read-error' } - } -}) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { - const input = String(startPath || '') - const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) - - try { - const stat = await fs.promises.stat(resolved) - const start = stat.isDirectory() ? resolved : path.dirname(resolved) - - return findGitRoot(start) - } catch { - return findGitRoot(resolved) - } -}) +ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e45ec8e804b..d03bd7cd0ad 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.test.ts b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts new file mode 100644 index 00000000000..bcaddad55b5 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/ipc.test.ts @@ -0,0 +1,100 @@ +/// + +import { Buffer } from 'node:buffer' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesReadDirEntry, HermesReadDirResult } from '@/global' + +import { clearProjectDirCache, readProjectDir } from './ipc' + +const readDir = vi.fn<(path: string) => Promise>() +const readFileDataUrl = vi.fn<(path: string) => Promise>() +const gitRoot = vi.fn<(path: string) => Promise>() + +function ok(entries: HermesReadDirEntry[]): HermesReadDirResult { + return { entries } +} + +function dataUrl(text: string) { + return `data:text/plain;base64,${Buffer.from(text, 'utf8').toString('base64')}` +} + +function installBridge() { + ;( + window as unknown as { + hermesDesktop: { + gitRoot: typeof gitRoot + readDir: typeof readDir + readFileDataUrl: typeof readFileDataUrl + } + } + ).hermesDesktop = { gitRoot, readDir, readFileDataUrl } +} + +describe('readProjectDir', () => { + beforeEach(() => { + clearProjectDirCache() + readDir.mockReset() + readFileDataUrl.mockReset() + gitRoot.mockReset() + installBridge() + }) + + afterEach(() => { + clearProjectDirCache() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('returns no-bridge when the desktop bridge is unavailable', async () => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + + await expect(readProjectDir('/repo')).resolves.toEqual({ entries: [], error: 'no-bridge' }) + }) + + it('filters gitignored entries when readDir returns Windows-style paths', async () => { + gitRoot.mockResolvedValue('C:\\repo') + readDir.mockImplementation(async path => { + if (path === 'C:\\repo\\src') { + return ok([ + { name: 'debug.log', path: 'C:\\repo\\src\\debug.log', isDirectory: false }, + { name: '临时.txt', path: 'C:\\repo\\src\\临时.txt', isDirectory: false }, + { name: 'keep.ts', path: 'C:\\repo\\src\\keep.ts', isDirectory: false } + ]) + } + + if (path === 'C:/repo') { + return ok([{ name: '.gitignore', path: 'C:/repo/.gitignore', isDirectory: false }]) + } + + if (path === 'C:/repo/src') { + return ok([]) + } + + return ok([]) + }) + readFileDataUrl.mockResolvedValue(dataUrl('# Unicode 路径规则\nsrc/*.log\nsrc/临时.txt\n')) + + const result = await readProjectDir('C:\\repo\\src', 'C:\\repo') + + expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts']) + expect(gitRoot).toHaveBeenCalledWith('C:/repo') + expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore') + }) + + it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => { + gitRoot.mockResolvedValue('/repo') + readDir.mockImplementation(async path => { + if (path === '/repo/src') { + return ok([{ name: 'debug.log', path: '/repo/src/debug.log', isDirectory: false }]) + } + + return ok([]) + }) + + const result = await readProjectDir('/repo/src', '/repo') + + expect(result.entries.map(entry => entry.name)).toEqual(['debug.log']) + expect(readFileDataUrl).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts index 843ebe761cd..078f0baab1e 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -27,7 +27,7 @@ function decodeDataUrl(dataUrl: string) { } function clean(path: string) { - return path.replace(/\/+$/, '') || '/' + return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/' } /** Strict POSIX-style relative path; null if `child` is not inside `root`. */ diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index 6421581ca8c..49cd72a8d27 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -145,7 +145,8 @@ function ProjectTreeRow({ } const isFolder = node.data.isDirectory - const isPlaceholder = node.data.id.endsWith('::__loading__') + const isPlaceholder = Boolean(node.data.placeholder) + const isErrorPlaceholder = node.data.placeholder === 'error' return (
} - {isPlaceholder ? ( + {isPlaceholder && !isErrorPlaceholder ? ( + ) : isErrorPlaceholder ? ( + ) : isFolder ? ( ) : ( diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts index a0ecd409f4a..d1c0018bf2e 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts @@ -106,7 +106,7 @@ describe('useProjectTree', () => { expect(readDir).toHaveBeenCalledTimes(1) }) - it('captures per-folder error code and leaves the folder expandable but empty', async () => { + it('captures per-folder error code and shows an error placeholder child', async () => { readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }])) readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) @@ -119,7 +119,14 @@ describe('useProjectTree', () => { }) expect(result.current.data[0].error).toBe('EACCES') - expect(result.current.data[0].children).toEqual([]) + expect(result.current.data[0].children).toEqual([ + { + id: '/p/priv::__error__', + isDirectory: false, + name: 'Unable to read (EACCES)', + placeholder: 'error' + } + ]) }) it('dedupes concurrent loadChildren calls for the same id', async () => { diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 23fb5efe2dc..3e022c19fd3 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -14,11 +14,14 @@ export interface TreeNode { children?: TreeNode[] /** True while a readDir for this folder is in flight. */ loading?: boolean + /** Synthetic loading/error rows are not real filesystem entries. */ + placeholder?: 'error' | 'loading' /** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */ error?: string } const PLACEHOLDER_ID = '__loading__' +const ERROR_PLACEHOLDER_ID = '__error__' function makeNode(path: string, name: string, isDirectory: boolean): TreeNode { return { id: path, isDirectory, name } @@ -43,7 +46,16 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: } function placeholderChild(parentId: string): TreeNode { - return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' } + return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' } +} + +function errorChild(parentId: string, error: string | undefined): TreeNode { + return { + id: `${parentId}::${ERROR_PLACEHOLDER_ID}`, + isDirectory: false, + name: `Unable to read (${error || 'read-error'})`, + placeholder: 'error' + } } export interface UseProjectTreeResult { @@ -227,7 +239,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { ...n, loading: false, error: error || undefined, - children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)) + children: error ? [errorChild(n.id, error)] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)) })) } })