Files
hermes-web-ui/tests/server/file-provider-paths.test.ts
Zhicheng Han 08b7813882 fix: safely show workspace symlink folders (#1854)
* fix: safely show workspace symlink folders

* docs: add chat-chain fragment for workspace picker
2026-06-30 19:30:00 +08:00

79 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { join, resolve } from 'path'
import { tmpdir } from 'os'
import { mkdir, mkdtemp, rm, symlink } from 'fs/promises'
import { normalizePlatformPath, validatePath } from '../../packages/server/src/services/hermes/file-provider'
import { isNearestExistingRealPathWithin, isPathWithin, isRealPathWithin, relativePathFromBase } from '../../packages/server/src/services/hermes/hermes-path'
describe('file provider platform path normalization', () => {
it('converts MSYS drive paths to Windows absolute paths on Windows', () => {
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'win32'))
.toBe('C:\\Users\\Administrator\\Desktop\\screenshot.png')
expect(normalizePlatformPath('/d/tmp/report.txt', 'win32'))
.toBe('D:\\tmp\\report.txt')
})
it('leaves MSYS-style paths unchanged on non-Windows platforms', () => {
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'darwin'))
.toBe('/c/Users/Administrator/Desktop/screenshot.png')
expect(normalizePlatformPath('/c/Users/Administrator/Desktop/screenshot.png', 'linux'))
.toBe('/c/Users/Administrator/Desktop/screenshot.png')
})
it('leaves normal Windows paths unchanged', () => {
expect(normalizePlatformPath('C:\\Users\\Administrator\\Desktop\\screenshot.png', 'win32'))
.toBe('C:\\Users\\Administrator\\Desktop\\screenshot.png')
})
it('allows literal double dots inside safe absolute path segments', () => {
const filePath = join(tmpdir(), 'foo..bar.txt')
expect(validatePath(filePath)).toBe(resolve(filePath))
})
it('rejects parent-directory traversal segments', () => {
const filePath = `${join(tmpdir(), 'safe')}/../evil.txt`
expect(() => validatePath(filePath)).toThrow('Invalid file path')
})
})
describe('Hermes path containment helpers', () => {
it('does not treat sibling paths with the same prefix as inside the base', () => {
expect(isPathWithin('/tmp/hermes-profile2/state.db', '/tmp/hermes-profile')).toBe(false)
expect(isPathWithin('/tmp/hermes-profile/state.db', '/tmp/hermes-profile')).toBe(true)
})
it('returns normalized relative paths only for children', () => {
expect(relativePathFromBase('/tmp/hermes-profile/logs/run.txt', '/tmp/hermes-profile'))
.toBe('logs/run.txt')
expect(relativePathFromBase('/tmp/hermes-profile2/logs/run.txt', '/tmp/hermes-profile'))
.toBeNull()
})
it('realpath-vets symlinked ancestors before allowing nested workspace operations', async () => {
const workspaceBase = await mkdtemp(join(tmpdir(), 'hermes-workspace-base-'))
const outsideRoot = await mkdtemp(join(tmpdir(), 'hermes-workspace-outside-'))
try {
const safeTarget = join(workspaceBase, 'projects')
const safeLink = join(workspaceBase, 'linked-projects')
const outsideTarget = join(outsideRoot, 'external-projects')
const outsideLink = join(workspaceBase, 'linked-external')
await mkdir(safeTarget, { recursive: true })
await mkdir(outsideTarget, { recursive: true })
await symlink(safeTarget, safeLink)
await symlink(outsideTarget, outsideLink)
await expect(isRealPathWithin(safeLink, workspaceBase)).resolves.toBe(true)
await expect(isRealPathWithin(outsideLink, workspaceBase)).resolves.toBe(false)
await expect(isNearestExistingRealPathWithin(join(safeLink, 'child'), workspaceBase)).resolves.toBe(true)
await expect(isNearestExistingRealPathWithin(join(outsideLink, 'child'), workspaceBase)).resolves.toBe(false)
} finally {
await rm(workspaceBase, { recursive: true, force: true })
await rm(outsideRoot, { recursive: true, force: true })
}
})
})