用户实测反馈:
1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
- 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
- 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
(slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
(14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
(键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
fs-watch 的变更通知显式失效
- 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)
2) 删除工作区路径检测(默认不检测、放开权限):
- path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
(schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
- ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
指南 §8.1 已加醒目声明
- 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
store prop + 相关 CSS 块
3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)
4) 修复「关闭文件后返回文件树,展开状态被重置」:
原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab
验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest'
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts'
|
|
|
|
/** The test workspace root (each suite gets its own temp tree). */
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
|
|
|
|
/** Symlink creation needs privileges on Windows; the link cases skip there. */
|
|
const canSymlink = (() => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-probe-'))
|
|
try {
|
|
symlinkSync(dir, join(dir, 'probe-link'))
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})()
|
|
|
|
afterAll(() => {
|
|
rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
/** Names of leftover temp files under `dir` (must be empty after every run). */
|
|
function tmpLeftovers(dir: string): string[] {
|
|
return readdirSync(dir).filter(name => name.includes('.dsh-upload-'))
|
|
}
|
|
|
|
/** Turn a string into the async-iterable chunk shape the route receives. */
|
|
function chunksOf(text: string): AsyncIterable<string | Uint8Array> {
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
// Split on two-byte boundaries so UTF-8 multi-byte sequences cross
|
|
// chunk edges (streaming must reassemble them as raw bytes, not text).
|
|
for (let i = 0; i < text.length; i += 2) yield text.slice(i, i + 2)
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('writeWorkspaceUpload', () => {
|
|
it('writes a file under the upload directory and returns its size', async () => {
|
|
const { path, size } = await writeWorkspaceUpload({
|
|
cwd: root,
|
|
dir: root,
|
|
relativePath: 'a.txt',
|
|
chunks: chunksOf('hello 世界'),
|
|
limit: 1024,
|
|
})
|
|
expect(path).toBe(join(root, 'a.txt'))
|
|
expect(size).toBe(Buffer.byteLength('hello 世界'))
|
|
expect(readFileSync(path, 'utf8')).toBe('hello 世界')
|
|
})
|
|
|
|
it('creates nested directories on demand (folder uploads)', async () => {
|
|
const { path } = await writeWorkspaceUpload({
|
|
cwd: root,
|
|
dir: root,
|
|
relativePath: 'docs/nested/deep.txt',
|
|
chunks: chunksOf('x'),
|
|
limit: 1024,
|
|
})
|
|
expect(existsSync(path)).toBe(true)
|
|
})
|
|
|
|
it('resolves relativePaths against the chosen directory', async () => {
|
|
const { path } = await writeWorkspaceUpload({
|
|
cwd: root,
|
|
dir: join(root, 'docs'),
|
|
relativePath: 'b.txt',
|
|
chunks: chunksOf('y'),
|
|
limit: 1024,
|
|
})
|
|
expect(path).toBe(join(root, 'docs', 'b.txt'))
|
|
})
|
|
|
|
it('refuses traversal, empty segments, and absolute relativePaths', async () => {
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: '../evil.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: './a.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'a/../b.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: '//', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
// Empty segments are refused, not silently collapsed.
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'a//b.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'a/b/', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
// Absolute paths (POSIX and Windows separators) are refused, not re-anchored.
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: '/x.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: '\\x.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})).rejects.toMatchObject({ code: 'bad-request' })
|
|
})
|
|
|
|
it('uploads into a directory outside the workspace (containment was removed)', async () => {
|
|
const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-outside-'))
|
|
try {
|
|
// ⚠️ PERMISSION CHANGE: the workspace fence is gone, so an absolute
|
|
// directory the host user can write is a valid upload target.
|
|
const result = await writeWorkspaceUpload({
|
|
cwd: root, dir: outside, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024,
|
|
})
|
|
expect(result).toEqual({ path: join(outside, 'x.txt'), size: 1 })
|
|
expect(readFileSync(join(outside, 'x.txt'), 'utf8')).toBe('x')
|
|
} finally {
|
|
rmSync(outside, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('follows an upload directory symlink outside the workspace (no realpath guard)', async () => {
|
|
if (!canSymlink) return
|
|
const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-symlink-outside-'))
|
|
const link = join(root, 'upload-link')
|
|
try {
|
|
symlinkSync(outside, link)
|
|
const viaLink = await writeWorkspaceUpload({
|
|
cwd: root, dir: link, relativePath: 'a.txt', chunks: chunksOf('a'), limit: 1024,
|
|
})
|
|
expect(viaLink.path).toBe(join(link, 'a.txt'))
|
|
// …and a relative path that walks through the link lands there too.
|
|
const viaRelative = await writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'upload-link/b.txt', chunks: chunksOf('b'), limit: 1024,
|
|
})
|
|
expect(viaRelative.path).toBe(join(root, 'upload-link', 'b.txt'))
|
|
expect(readFileSync(join(outside, 'a.txt'), 'utf8')).toBe('a')
|
|
expect(readFileSync(join(outside, 'b.txt'), 'utf8')).toBe('b')
|
|
} finally {
|
|
rmSync(link, { force: true })
|
|
rmSync(outside, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('refuses oversized uploads without leaving a target or temp file', async () => {
|
|
const target = join(root, 'big.bin')
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'big.bin', chunks: chunksOf('1234567890'), limit: 4,
|
|
})).rejects.toMatchObject({ code: 'too-large' })
|
|
expect(existsSync(target)).toBe(false)
|
|
expect(tmpLeftovers(root)).toEqual([])
|
|
})
|
|
|
|
it('keeps concurrent uploads to the same target independent', async () => {
|
|
const target = join(root, 'race.txt')
|
|
await Promise.all([
|
|
writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('first'), limit: 1024 }),
|
|
writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('second'), limit: 1024 }),
|
|
])
|
|
// Both renames succeed (unique temp names, no EEXIST cross-talk); the last
|
|
// rename wins and the losers leave nothing behind.
|
|
expect(['first', 'second']).toContain(readFileSync(target, 'utf8'))
|
|
expect(tmpLeftovers(root)).toEqual([])
|
|
})
|
|
|
|
it('does not overwrite an existing file on a failed (oversized) retry', async () => {
|
|
const target = join(root, 'keep.txt')
|
|
await writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('original'), limit: 1024 })
|
|
await expect(writeWorkspaceUpload({
|
|
cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('0123456789'), limit: 2,
|
|
})).rejects.toMatchObject({ code: 'too-large' })
|
|
expect(readFileSync(target, 'utf8')).toBe('original')
|
|
})
|
|
})
|
|
|
|
describe('renameWorkspaceEntry', () => {
|
|
it('renames a file within its directory', async () => {
|
|
writeFileSync(join(root, 'old-name.txt'), 'x')
|
|
const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' })
|
|
// The returned path is CANONICAL (realpath-resolved ancestors — on macOS
|
|
// the tmpdir's /var becomes /private/var), so assert the shape, not the
|
|
// lexical spelling.
|
|
expect(path.endsWith('new-name.txt')).toBe(true)
|
|
expect(existsSync(join(root, 'old-name.txt'))).toBe(false)
|
|
expect(existsSync(join(root, 'new-name.txt'))).toBe(true)
|
|
})
|
|
|
|
it('renames a directory (recursive content moves with it)', async () => {
|
|
mkdirSync(join(root, 'olddir/nested'), { recursive: true })
|
|
writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x')
|
|
await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' })
|
|
expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x')
|
|
})
|
|
|
|
it('is a no-op for the same name (destination-exists refusal must not bite)', async () => {
|
|
writeFileSync(join(root, 'same.txt'), 'x')
|
|
await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' })
|
|
expect(existsSync(join(root, 'same.txt'))).toBe(true)
|
|
})
|
|
|
|
it('refuses single-segment violations (empty, dot, traversal, separators)', async () => {
|
|
writeFileSync(join(root, 'r.txt'), 'x')
|
|
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
|
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name }))
|
|
.rejects.toMatchObject({ code: 'bad-request' })
|
|
}
|
|
})
|
|
|
|
it('refuses an existing destination instead of clobbering it', async () => {
|
|
writeFileSync(join(root, 'src.txt'), 'src')
|
|
writeFileSync(join(root, 'dst.txt'), 'dst')
|
|
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' }))
|
|
.rejects.toMatchObject({ code: 'fs-error', status: 409 })
|
|
expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst')
|
|
})
|
|
|
|
it('refuses the workspace root and missing sources', async () => {
|
|
await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' }))
|
|
.rejects.toMatchObject({ code: 'fs-error' })
|
|
await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' }))
|
|
.rejects.toMatchObject({ code: 'fs-error' })
|
|
})
|
|
|
|
it('renames a symlink ROW, not its target', async () => {
|
|
writeFileSync(join(root, 'target.txt'), 't')
|
|
symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt'))
|
|
await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' })
|
|
expect(existsSync(join(root, 'alias.txt'))).toBe(false)
|
|
expect(existsSync(join(root, 'alias2.txt'))).toBe(true)
|
|
expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t')
|
|
})
|
|
})
|
|
|
|
describe('removeWorkspaceEntry', () => {
|
|
it('unlinks a file', async () => {
|
|
writeFileSync(join(root, 'gone.txt'), 'x')
|
|
await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') })
|
|
expect(existsSync(join(root, 'gone.txt'))).toBe(false)
|
|
})
|
|
|
|
it('removes a directory recursively', async () => {
|
|
mkdirSync(join(root, 'tree/sub'), { recursive: true })
|
|
writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x')
|
|
await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') })
|
|
expect(existsSync(join(root, 'tree'))).toBe(false)
|
|
})
|
|
|
|
it('unlinks a symlink row without touching (or recursing into) its target', async () => {
|
|
mkdirSync(join(root, 'realdir'), { recursive: true })
|
|
writeFileSync(join(root, 'realdir/keep.txt'), 'x')
|
|
symlinkSync(join(root, 'realdir'), join(root, 'linkdir'))
|
|
await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') })
|
|
expect(existsSync(join(root, 'linkdir'))).toBe(false)
|
|
expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x')
|
|
})
|
|
|
|
it('refuses the workspace root and missing paths', async () => {
|
|
await expect(removeWorkspaceEntry({ cwd: root, path: root }))
|
|
.rejects.toMatchObject({ code: 'fs-error' })
|
|
await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') }))
|
|
.rejects.toMatchObject({ code: 'fs-error' })
|
|
})
|
|
})
|