用户实测反馈:
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)
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
/**
|
|
* File mutations for the sidebar (the upload route and the tree's renames,
|
|
* deletes and mkdirs).
|
|
*
|
|
* ⚠️ **No workspace containment any more.** The guard was removed at the
|
|
* user's request, so these operations reach whatever the HOST USER can reach.
|
|
* What is still enforced is the SHAPE of a request: the relative upload path
|
|
* is sanitized (absolute paths, '.', '..' and empty segments are refused), a
|
|
* rename/mkdir name must be one path segment, an existing destination is
|
|
* refused instead of clobbered, and the session workspace root itself is never
|
|
* renamable or removable. Bytes stream from the request body to a uniquely
|
|
* named temp sibling and are renamed into place, so a failed, aborted, or
|
|
* oversized upload never leaves a partial file at the target path.
|
|
*
|
|
* The tree's rename/delete are link-aware: they address the LEXICAL row path
|
|
* (lstat decides), so renaming or deleting a symlink row renames/unlinks the
|
|
* LINK, never its target — matching what the tree row visually names (VS Code
|
|
* semantics). Every mutation invalidates the directory cache of the level(s)
|
|
* it touched.
|
|
*/
|
|
import { randomUUID } from 'node:crypto'
|
|
import { once } from 'node:events'
|
|
import { createWriteStream } from 'node:fs'
|
|
import { access, lstat, mkdir, rename, rm, stat, unlink } from 'node:fs/promises'
|
|
import { basename, dirname, join } from 'node:path'
|
|
import { invalidateDirectoryCache, requireAbsolute } from './fs-tree.ts'
|
|
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
|
|
import { resolveSessionPath } from './session-path.ts'
|
|
import { SidebarError } from './wire.ts'
|
|
|
|
/** Inputs of one upload: the session scope plus the request body stream. */
|
|
export interface WorkspaceUploadInput {
|
|
/** The session workspace root (the base of session-relative targets). */
|
|
cwd: string
|
|
/** Absolute upload directory chosen by the client. */
|
|
dir: string
|
|
/** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */
|
|
relativePath: string
|
|
/** The request body stream (raw bytes). */
|
|
chunks: AsyncIterable<string | Uint8Array>
|
|
/** Byte cap; an oversized upload is refused without touching the target. */
|
|
limit: number
|
|
/** @deprecated IGNORED — containment was removed. Do not pass it. */
|
|
fence?: boolean
|
|
}
|
|
|
|
/**
|
|
* Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
|
|
* sibling receives the bytes, then is renamed over the target. The parent
|
|
* directory is created on demand (recursive), so folder uploads work before
|
|
* any level exists. The unique temp name keeps concurrent uploads to the same
|
|
* target independent (each writes and renames its own file; the last rename
|
|
* wins) and never blocks later uploads after a crashed process.
|
|
*
|
|
* @throws SidebarError with a wire code for shape and size failures; the temp
|
|
* file is always removed on failure.
|
|
*/
|
|
export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> {
|
|
const { cwd, dir, relativePath, chunks, limit } = input
|
|
const base = requireAbsolute(dir)
|
|
await ensureWorkspacePath(cwd, base)
|
|
if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
|
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
|
|
}
|
|
const segments = relativePath.split(/[\\/]/)
|
|
if (segments.some(part => part === '' || part === '.' || part === '..')) {
|
|
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
|
|
}
|
|
const target = join(base, ...segments)
|
|
const safeTarget = await ensureWorkspaceWritePath(cwd, target)
|
|
const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`)
|
|
await mkdir(dirname(safeTarget), { recursive: true })
|
|
const stream = createWriteStream(tmp, { flags: 'wx' })
|
|
// Resolves once the stream fully closes; created up front so a stream that
|
|
// already closed (successful end, later failure) cannot leave the wait hanging.
|
|
const closed = new Promise<void>((resolve) => { stream.once('close', () => resolve()) })
|
|
let size = 0
|
|
let streamError: unknown
|
|
// A permanent 'error' listener keeps a failing disk from crashing the host:
|
|
// every await below surfaces the failure through the promise chain instead.
|
|
stream.on('error', (error) => { streamError = error })
|
|
try {
|
|
for await (const chunk of chunks) {
|
|
const buffer = Buffer.from(chunk)
|
|
size += buffer.length
|
|
if (size > limit) throw new SidebarError('too-large', `upload exceeds the ${limit} byte limit`, 413)
|
|
if (!stream.write(buffer)) await once(stream, 'drain')
|
|
if (streamError !== undefined) throw streamError
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error)))
|
|
})
|
|
if (streamError !== undefined) throw streamError
|
|
await rename(tmp, safeTarget)
|
|
const info = await stat(safeTarget)
|
|
// The target's level (and, for a new folder, its parent) is now stale.
|
|
invalidateDirectoryCache(dirname(safeTarget))
|
|
return { path: target, size: info.size }
|
|
} catch (error) {
|
|
// Wait for the stream to fully close before unlinking (Windows locks open
|
|
// files), then remove our own uniquely named temp file.
|
|
stream.destroy()
|
|
await closed.catch(() => {})
|
|
await rm(tmp, { force: true }).catch(() => {})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** Inputs of one tree-row rename. */
|
|
export interface WorkspaceRenameInput {
|
|
/** The session workspace root (the base of session-relative targets). */
|
|
cwd: string
|
|
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
|
path: string
|
|
/** The new base name (single segment — rename never moves across directories). */
|
|
name: string
|
|
/** @deprecated IGNORED — containment was removed. Do not pass it. */
|
|
fence?: boolean
|
|
}
|
|
|
|
/**
|
|
* Resolve one existing entry for a link-aware mutation: the lexical row path
|
|
* plus the resolved workspace root (for the "never rename/remove the root"
|
|
* check). No realpath, no containment: the path exists (lstat decides) and the
|
|
* operation addresses it as written.
|
|
*/
|
|
async function resolveEntry(
|
|
cwd: string,
|
|
target: string,
|
|
): Promise<{ absolute: string; real: string; realCwd: string }> {
|
|
const absolute = requireAbsolute(resolveSessionPath(cwd, target))
|
|
const realCwd = requireAbsolute(cwd)
|
|
return { absolute, real: absolute, realCwd }
|
|
}
|
|
|
|
/** Whether a path exists (ENOENT → false; other failures propagate). */
|
|
async function pathExists(target: string): Promise<boolean> {
|
|
try {
|
|
await access(target)
|
|
return true
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename one tree row within its directory: `path` → `<parent>/<name>`.
|
|
* The new name must be a single path segment (this is rename, not move);
|
|
* an existing destination is refused (POSIX rename would clobber it
|
|
* silently); the workspace root itself is never renamable; a symlink row
|
|
* renames the link, not its target. A no-op rename (same name) succeeds
|
|
* without touching the filesystem.
|
|
*
|
|
* @throws SidebarError with a wire code for shape, existence and root
|
|
* failures.
|
|
*/
|
|
export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
|
|
const { cwd, path, name } = input
|
|
if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
throw new SidebarError('bad-request', 'name must be a single path segment', 400)
|
|
}
|
|
const { absolute, real, realCwd } = await resolveEntry(cwd, path)
|
|
if (real === realCwd) {
|
|
throw new SidebarError('fs-error', 'cannot rename the workspace root', 400)
|
|
}
|
|
if (basename(absolute) === name) return { path: absolute }
|
|
const destination = join(dirname(absolute), name)
|
|
const safeDestination = await ensureWorkspaceWritePath(cwd, destination)
|
|
if (await pathExists(safeDestination)) {
|
|
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
|
|
}
|
|
try {
|
|
await rename(absolute, safeDestination)
|
|
} catch (error) {
|
|
throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
}
|
|
invalidateDirectoryCache(dirname(safeDestination))
|
|
return { path: safeDestination }
|
|
}
|
|
|
|
/** Inputs of one new directory row. */
|
|
export interface WorkspaceMkdirInput {
|
|
/** The session workspace root (the base of session-relative targets). */
|
|
cwd: string
|
|
/** Absolute path of the PARENT row as the tree displays it (a directory). */
|
|
path: string
|
|
/** The new directory's base name (single segment — mkdir never nests). */
|
|
name: string
|
|
/** @deprecated IGNORED — containment was removed. Do not pass it. */
|
|
fence?: boolean
|
|
}
|
|
|
|
/**
|
|
* Create one directory inside an existing tree row: `<path>/<name>`.
|
|
* The name must be a single path segment; an existing destination is refused
|
|
* (mkdir would otherwise fail with EEXIST anyway, but the explicit check
|
|
* yields the same "already exists" sentence rename uses); the parent row may
|
|
* be any directory the host user can write.
|
|
*
|
|
* @throws SidebarError with a wire code for shape and existence failures.
|
|
*/
|
|
export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{ path: string }> {
|
|
const { cwd, path, name } = input
|
|
if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
throw new SidebarError('bad-request', 'name must be a single path segment', 400)
|
|
}
|
|
const { absolute } = await resolveEntry(cwd, path)
|
|
const destination = await ensureWorkspaceWritePath(cwd, join(absolute, name))
|
|
if (await pathExists(destination)) {
|
|
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
|
|
}
|
|
try {
|
|
await mkdir(destination)
|
|
} catch (error) {
|
|
throw new SidebarError('fs-error', `cannot create "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
}
|
|
// The PARENT level gained a row; the new directory's own level is empty.
|
|
invalidateDirectoryCache(absolute)
|
|
return { path: destination }
|
|
}
|
|
|
|
/** Inputs of one tree-row delete. */
|
|
export interface WorkspaceRemoveInput {
|
|
/** The session workspace root (the base of session-relative targets). */
|
|
cwd: string
|
|
/** Absolute path of the row as the tree displays it (may be a symlink). */
|
|
path: string
|
|
/** @deprecated IGNORED — containment was removed. Do not pass it. */
|
|
fence?: boolean
|
|
}
|
|
|
|
/**
|
|
* Delete one tree row permanently (there is no trash on the host): files are
|
|
* unlinked, directories removed recursively, a symlink row unlinks the LINK
|
|
* only (lstat decides, so a link to a directory does not recurse into its
|
|
* target). The workspace root itself is never removable.
|
|
*
|
|
* @throws SidebarError with a wire code for existence and root failures.
|
|
*/
|
|
export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
|
|
const { cwd, path } = input
|
|
const { absolute, real, realCwd } = await resolveEntry(cwd, path)
|
|
if (real === realCwd) {
|
|
throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
|
|
}
|
|
try {
|
|
const info = await lstat(absolute)
|
|
if (info.isDirectory()) await rm(absolute, { recursive: true })
|
|
else await unlink(absolute)
|
|
} catch (error) {
|
|
throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
|
}
|
|
invalidateDirectoryCache(dirname(absolute))
|
|
return { path: absolute }
|
|
}
|