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:
+60
-62
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user