perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话

用户实测反馈:

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)
This commit is contained in:
Menghuan1918
2026-09-28 20:37:56 +08:00
parent f29743f03f
commit 5c95002641
68 changed files with 1313 additions and 747 deletions
+60 -62
View File
@@ -1,37 +1,38 @@
/**
* Workspace-safe file mutations for the sidebar (the upload route today).
* File mutations for the sidebar (the upload route and the tree's renames,
* deletes and mkdirs).
*
* Every write is confined to the real session workspace: the upload
* directory is resolved absolute and its target is checked through existing
* filesystem ancestors, the relative path is sanitized (absolute paths, '.',
* '..' and empty segments are refused), and the final target must stay inside
* the workspace after symlink resolution. 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.
* ⚠️ **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 (below) are link-aware: existence and containment
* are verified against the fully resolved target (a symlink pointing outside
* the workspace is refused while the fence is armed), but the operation
* itself addresses the lexical row path — renaming or deleting a symlink
* row renames/unlinks the LINK, never its target, matching what the tree
* row visually names (VS Code semantics).
* 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, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
import { access, lstat, mkdir, rename, rm, stat, unlink } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { isWithin, requireAbsolute } from './fs-tree.ts'
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; target and directory must stay inside it. */
/** The session workspace root (the base of session-relative targets). */
cwd: string
/** Absolute upload directory chosen by the client (inside `cwd`). */
/** Absolute upload directory chosen by the client. */
dir: string
/** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */
relativePath: string
@@ -39,7 +40,7 @@ export interface WorkspaceUploadInput {
chunks: AsyncIterable<string | Uint8Array>
/** Byte cap; an oversized upload is refused without touching the target. */
limit: number
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
/** @deprecated IGNORED — containment was removed. Do not pass it. */
fence?: boolean
}
@@ -51,13 +52,13 @@ export interface WorkspaceUploadInput {
* 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 containment, shape, and size
* failures; the temp file is always removed on failure.
* @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, fence = true } = input
const { cwd, dir, relativePath, chunks, limit } = input
const base = requireAbsolute(dir)
await ensureWorkspacePath(cwd, base, fence)
await ensureWorkspacePath(cwd, base)
if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
@@ -66,7 +67,7 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
}
const target = join(base, ...segments)
const safeTarget = await ensureWorkspaceWritePath(cwd, target, fence)
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' })
@@ -92,6 +93,8 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
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
@@ -105,36 +108,29 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
/** Inputs of one tree-row rename. */
export interface WorkspaceRenameInput {
/** The session workspace root; the renamed entry must stay inside it. */
/** 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
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
/** @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 its fully resolved real target (fence-checked). ENOENT becomes an
* fs-error, mirroring path-security's resolveRealPath semantics. */
/**
* 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,
fence: boolean,
): Promise<{ absolute: string; real: string; realCwd: string }> {
const absolute = requireAbsolute(resolveSessionPath(cwd, target))
let real: string
let realCwd: string
try {
;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)])
} catch (error) {
throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
if (fence && !isWithin(realCwd, real)) {
throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
}
return { absolute, real, realCwd }
const realCwd = requireAbsolute(cwd)
return { absolute, real: absolute, realCwd }
}
/** Whether a path exists (ENOENT → false; other failures propagate). */
@@ -156,21 +152,21 @@ async function pathExists(target: string): Promise<boolean> {
* 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, containment, existence
* and root failures.
* @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, fence = true } = input
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, fence)
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, fence)
const safeDestination = await ensureWorkspaceWritePath(cwd, destination)
if (await pathExists(safeDestination)) {
throw new SidebarError('fs-error', `"${name}" already exists`, 409)
}
@@ -179,18 +175,19 @@ export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise
} 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 new directory must stay inside it. */
/** 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
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
/** @deprecated IGNORED — containment was removed. Do not pass it. */
fence?: boolean
}
@@ -198,19 +195,18 @@ export interface WorkspaceMkdirInput {
* 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 must
* resolve inside the (fence-checked) workspace.
* 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, containment and existence
* failures.
* @throws SidebarError with a wire code for shape and existence failures.
*/
export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{ path: string }> {
const { cwd, path, name, fence = true } = input
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, fence)
const destination = await ensureWorkspaceWritePath(cwd, join(absolute, name), fence)
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)
}
@@ -219,16 +215,18 @@ export async function mkdirWorkspaceEntry(input: WorkspaceMkdirInput): Promise<{
} 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 removed entry must stay inside it. */
/** 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
/** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
/** @deprecated IGNORED — containment was removed. Do not pass it. */
fence?: boolean
}
@@ -238,12 +236,11 @@ export interface WorkspaceRemoveInput {
* 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 containment, existence and
* root failures.
* @throws SidebarError with a wire code for existence and root failures.
*/
export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
const { cwd, path, fence = true } = input
const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
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)
}
@@ -254,5 +251,6 @@ export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise
} catch (error) {
throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
invalidateDirectoryCache(dirname(absolute))
return { path: absolute }
}