mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
* fix(desktop): Harden local file tree paths Normalize Electron local path handling across file tree, preview, media, and git-root flows. Reject malformed and Windows device paths, recheck sensitive files after realpath resolution, and preserve external symlink traversal with stable renderer errors. * fix(desktop): Address file tree review feedback
364 lines
11 KiB
JavaScript
364 lines
11 KiB
JavaScript
'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
|
|
)
|
|
})
|