diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..82c7602 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Node regression +on: [push, pull_request] +jobs: + test: + strategy: + matrix: + os: [windows-latest, ubuntu-latest] + node: [20, 22, 24] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run check + - run: npm test + - run: npm audit --omit=dev diff --git a/README.md b/README.md index e158e1e..3085797 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ **v1.9.0 可回滚写入与目录级策略**:全部文本修改改为「完整载荷 → 独占暂存 → 独立指纹校验 → 原文件备份 → 提交 → 最终校验」事务流程,失败自动回滚,回滚失败保留 `recoveryPath`;加密策略按「目标目录 × 扩展名」实时探测,人工标注(`mark_extension`)独立持久化到 `.mcp-file-policies/`,刷新探测/重启/TTL 过期均不丢失;safeWrite 失败不再回退直写(保护原文);复制/移动目录逐文件执行相同策略;所有工具返回统一 `structuredContent`(ok/code/changed/data/warnings)。 +**v1.9.1 边界修复**:目录复制/移动拒绝双向祖先重叠,避免覆盖未读取的源文件;回滚失败和部分删源正确保留 `changed` / `partial`;行分页无法容纳首行时明确报错,到达请求结束行即停止解析,避免被范围外长行影响。Hono 间接依赖更新至 4.13.5。 + +**v1.9.2 并发与异常修复**:目录与子路径操作跨实例互斥,无关路径仍可并行;提交及锁清理错误单独记录为 `cleanupErrors`,保留真实修改状态与恢复路径;递归删除逐项记录部分结果;字面量替换在可终止工作线程中一次拼接,并在预览或提交前检查时间预算。 + +**v1.9.3 补充修复**:工作根保护同时识别配置别名和真实路径,并拒绝操作包含受保护根的祖先;移动失败时计入已经删除的源目录;策略探测参与父目录锁;目录操作失败保留之前子项的清理诊断;glob去重、累计限额并分批让出主线程,避免过滤条件绕过超时或阻塞心跳。 + ## 适用场景 电脑安装了文件加密软件(如天锐绿盾、IP-Guard、亿赛通、深信服等),磁盘上的文件是密文。AI Agent(Claude Code、Cursor、Windsurf、Cline 等)是独立进程,内置文件工具不在白名单内,只能读到密文。而 Node.js 进程在白名单内,通过 MCP Server 提供的替代工具可以正常读写明文。 @@ -38,9 +44,10 @@ AI Agent --(MCP/stdio)--> Node.js MCP Server(index.js) --(lib/ 模块)--> fs - `index.js` 仅负责 stdio 启动与正则 worker 回收 - `lib/encryption.js` 目录级加密探测、写入策略决策、safeWrite 组合与独立指纹校验 - `lib/files.js` 路径边界、跨实例锁、可回滚提交(暂存→备份→rename→终验)、目录逐文件复制/移动 +- `lib/locks.js` 登记路径及子树占用;`lib/cleanup.js` 保留主操作结果并收集清理错误 - `lib/text.js` 严格 UTF-8 增量解码与流式分页 - `lib/patterns.js` 无回溯 glob 与保留原索引的字符串替换 -- `lib/regex.js` / `lib/regex-worker.js` 用户正则在可终止 worker 中执行(默认 1 秒预算) +- `lib/regex.js` / `lib/regex-worker.js` 用户正则与字面量编辑在可终止 worker 中执行(单次计算默认最多 1 秒,并受请求总预算约束) - `lib/server.js` 注册全部 18 个 MCP 工具,统一 structuredContent 与超时/只读包装 ## 环境自适应(v1.7.0+) @@ -113,9 +120,14 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数: - **完整载荷**:追加模式先在内存合成「原内容+新增」完整内容再走事务,纠正/重写不会丢原文与 BOM - **safeWrite 失败即中止**:不再回退直写破坏原文(SAFE_WRITE_FAILED,changed=false) -- **跨实例锁**:同一路径的并发写入经 `.mcp-file-locks/` 互斥(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT) +- **跨实例锁**:使用同一 `MCP_PROFILE_DIR` 的实例经 `.mcp-file-locks/` 登记整组路径,同路径及祖先/后代相互排斥,无关路径可并行(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT)。升级到 1.9.2 时应重启全部 MCP 实例,避免旧版进程继续使用不同的锁协议 +- **清理状态**:提交或锁清理失败会附带 `cleanupErrors`;主操作已成功时保留成功结果和真实 `changed`,错误时保留原错误及 `recoveryPath`。不要因清理告警重复追加内容 +- **递归删除**:逐项执行,失败时返回 `changed`、`partial`(最多100项)、`removedCount`、`partialTruncated`、`failedPath`;受一万项和128层预算限制,不是整树事务 - **断电/强杀残留**:两次 rename 之间的极端崩溃可能留下 `.mcp-backup-*` 与 `.mcp-stage-*`,先核对内容与时间再人工恢复,禁止直接批量清理 -- **复制/移动目录**:逐文件执行相同策略;移动先复制并二次比对指纹后再删除已验证的源文件;失败返回 `partial` 与 `sourceRetained`,不静默回退;符号链接明确拒绝;递归目标(目标在源内)明确拒绝 +- **复制/移动目录**:逐文件执行相同策略;移动先复制并二次比对指纹后再删除已验证的源文件;失败返回 `partial` 与 `sourceRetained`,不静默回退;符号链接明确拒绝;源和最终目标存在任一方向的祖先关系时拒绝,相同路径保持不变。回滚失败时 `changed=true`,`partial` 包含当前失败目标,原备份通过 `recoveryPath` 返回 +- **移动失败的源变化**:`sourceRetained` 表示本次是否尚未删除任何源文件或源目录;`removedSourceCount`、`removedSourcePaths`(最多100项)、`removedSourcePathsTruncated` 报告已经删除的源项。失败仍保留已完成子项的 `cleanupErrors` +- **策略探测互斥**:`inspect_write_strategy` 持有目标父目录锁,创建和清理探测样本完成后才允许该目录被复制、移动或删除 +- **glob总预算**:生产搜索与查找使用异步匹配,合并重复模式和分支;一次请求的展开后累计长度最多10万,编译和全部路径匹配共用5000万工作单元预算。约每16384工作单元让出事件循环,检查取消与截止时间;超过限额返回 `GLOB_LIMIT`,超时返回 `TIMEOUT` - **diskState 三态**:`plaintext`(独立进程验证磁盘明文)、`preserved`(保持加密直写)、`unknown`(内容已校验但无独立读取器证明磁盘状态——不能当作「已证明明文」) ## 文件结构 @@ -322,7 +334,7 @@ claude mcp list - 读取第 10 行:`mode="lines"`, `startLine=10` - 读取第 5-20 行:`mode="lines"`, `startLine=5`, `endLine=20` -> 返回内容会带文件名、读取范围、总行数的头部信息,行模式下每行带行号前缀。超出文件范围时自动截断并提示;行模式流式扫描返回 `nextLine` 供续页。 +> 行模式文本带行号,结构化结果含 `lines`、`nextLine`、`truncated`、`totalLines`。到达请求结束行即停止;未扫描到 EOF 时 `totalLines=null`,`nextLine` 是待确认的续读起点,可能已超过 EOF(下一次调用会明确报 `LINE_OUT_OF_RANGE`)。首行加上行号开销超过页预算时返回 `LINE_TOO_LONG`,请改用字符分页,不会返回游标不前进的成功空页。 ### `edit_file` 换行符自动兼容 @@ -522,7 +534,7 @@ v1.9.0 起 safeWrite 失败**不再回退直写**(保护原文件,返回 `ch ### 写工具返回 FILE_BUSY / CONFLICT -- `FILE_BUSY`:另一个 MCP 实例正在写同一路径(锁位于 `~/.mcp-file-locks/`)。确认对方进程结束后重试;仅当确认锁属主进程已退出时才可人工删除残留锁文件 +- `FILE_BUSY`:另一个 MCP 实例正在操作同一路径、父目录或子路径(锁位于 `~/.mcp-file-locks/`)。确认对方进程结束后重试;异常退出时仅在核对记录中的 PID 后处理残留 `.lock` 或 `.registry-guard`。`LOCK_CLEANUP_FAILED` 和 `cleanupErrors` 会给出未清理路径,不应直接清空整个锁目录 - `CONFLICT`:传入的 `expectedHash` 与文件当前指纹不一致——文件在您读取后被其他进程改过。重新 read_file 后再编辑 ### 回滚失败返回 recoveryPath diff --git a/SKILL.md b/SKILL.md index c5c1dc5..b73d4e8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -20,7 +20,7 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件 ## 必须理解的结果 1. 优先读取structuredContent的ok、code、changed、data和warnings,不能只看人类文本。 -2. isError=true时可能存在目录操作部分目标;查看partial/sourceRetained。单文件回滚失败时查看recoveryPath并保留备份。 +2. isError=true时可能存在目录操作部分目标;查看changed/partial/sourceRetained。递归删除还需查看removedCount/partialTruncated/failedPath。单文件回滚失败时查看recoveryPath并保留备份。cleanupErrors只是清理诊断,成功修改后不得因清理告警重复追加。 3. contentVerified表示Node可见内容一致;diskVerified才表示独立读取器的完整磁盘指纹一致。unknown不能声称已落盘明文。 4. check_status基础调用不探测环境。文件可读取也不能直接推断解密正常,可信expectedHash匹配才提供明确内容对照。 5. 文本工具只支持有效UTF8;UTF16、GBK、非法字节或NUL被拒绝时,必须先明确转换编码,不能强制按UTF8写回。 diff --git a/lib/cleanup.js b/lib/cleanup.js new file mode 100644 index 0000000..5023a39 --- /dev/null +++ b/lib/cleanup.js @@ -0,0 +1,34 @@ +/** 清理只收集自身错误,不覆盖主操作结果或恢复信息。 */ +const fs = require('node:fs/promises'); +const { READ_LIMIT, safeEnd } = require('./text'); + +/** 尝试删除全部指定临时文件,并返回有界的清理诊断。 */ +async function cleanupFiles(paths) { + const errors = []; + for (const file of paths) { + try { await fs.rm(file, { force: true }); } + catch (error) { errors.push({ path: file, code: error.code || 'CLEANUP_FAILED', message: error.message }); } + } + return errors; +} + +/** 失败保留主异常,成功则附加清理告警;兼容文件结果与MCP响应。 */ +function finishCleanup(value, failure, errors) { + if (failure) { + if (errors.length) failure.cleanupErrors = [...(failure.cleanupErrors || []), ...errors].slice(0, 100); + throw failure; + } + if (!errors.length) return value; + const response = value.structuredContent; + const data = response ? response.data : value; + data.cleanupErrors = [...(data.cleanupErrors || []), ...errors].slice(0, 100); + const warning = '操作已完成,但有临时文件或锁未能清理;请查看cleanupErrors,不要重复提交已完成的修改'; + if (response) { + response.warnings = [...response.warnings, warning]; + const body = value.content.find(item => item.type === 'text'); + if (body) body.text = body.text.slice(0, safeEnd(body.text, READ_LIMIT)) + '\n注意:' + warning; + } else value.warnings = [...(value.warnings || []), warning]; + return value; +} + +module.exports = { cleanupFiles, finishCleanup }; diff --git a/lib/files.js b/lib/files.js index ee73d33..ab72116 100644 --- a/lib/files.js +++ b/lib/files.js @@ -4,18 +4,9 @@ const nativeFs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const { pipeline } = require('node:stream/promises'); -const { setTimeout: delay } = require('node:timers/promises'); -const { performance } = require('node:perf_hooks'); const { fault, fingerprint, payloadFingerprint, checkBudget } = require('./text'); - -/** 对 Windows 文件路径统一大小写,用于同一路径和锁键比较。 */ -function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); } - -/** 判定 candidate 是否等于 base 或位于其子树,避免字符串前缀误判。 */ -function inside(base, candidate) { - const rel = path.relative(pathKey(base), pathKey(candidate)); - return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel)); -} +const { createLocker, pathKey, inside } = require('./locks'); +const { cleanupFiles, finishCleanup } = require('./cleanup'); /** 从最近存在的祖先解析真实路径,识别目录符号链接和 junction 越界。 */ async function canonical(file) { @@ -41,6 +32,8 @@ function createFiles(encryption, options = {}) { const configuredRoots = options.allowedRoots ?? (process.env.MCP_ALLOWED_ROOTS ? JSON.parse(process.env.MCP_ALLOWED_ROOTS) : []); if (!Array.isArray(configuredRoots) || !configuredRoots.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('INVALID_CONFIG', 'MCP_ALLOWED_ROOTS 必须是绝对路径 JSON 数组'); const rootsPromise = Promise.all(configuredRoots.map(canonical)); + const locked = createLocker(encryption.stateDir); + let realBasePromise; /** 在操作前解析路径并执行服务端权限范围检查,删除链接时只解析其父目录。 */ async function resolve(value, { write = false, leaf = false, destructive = false } = {}) { @@ -50,40 +43,16 @@ function createFiles(encryption, options = {}) { const real = leaf ? path.join(await canonical(path.dirname(absolute)), path.basename(absolute)) : await canonical(absolute); const roots = await rootsPromise; if (roots.length && !roots.some(root => inside(root, real))) throw fault('PATH_OUTSIDE_ROOTS', '路径不在允许的根目录内'); - if (destructive && (pathKey(real) === pathKey(path.parse(real).root) || pathKey(real) === pathKey(baseDir) || roots.some(root => pathKey(root) === pathKey(real)))) { - throw fault('PROTECTED_ROOT', '不能删除或移动盘符根、工作目录或配置根目录本身'); + if (destructive) { + // 同时保护配置别名和真实工作根;其他链接仍按leaf语义只删除链接本身。 + const realBase = await (realBasePromise ||= canonical(baseDir)); + if ([path.parse(real).root, baseDir, realBase, ...roots].some(root => inside(real, root))) { + throw fault('PROTECTED_ROOT', '不能删除或移动盘符根、工作目录、配置根目录或包含这些根的祖先目录'); + } } return real; } - /** 为多个路径按稳定顺序获取独占锁,避免多实例覆盖和死锁。 */ - async function locked(files, action, context = {}) { - const dir = path.join(encryption.stateDir, '.mcp-file-locks'); - await fs.mkdir(dir, { recursive: true }); - const held = []; - const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000); - try { - for (const file of [...new Set(files.map(pathKey))].sort()) { - const lock = path.join(dir, crypto.createHash('sha256').update(file).digest('hex') + '.lock'); - while (true) { - checkBudget(context.signal, context.deadline); - try { - const handle = await fs.open(lock, 'wx', 0o600); - held.push(lock); - try { await handle.writeFile(JSON.stringify({ pid: process.pid, file, createdAt: new Date().toISOString() })); } - finally { await handle.close(); } - break; - } catch (error) { - if (error.code !== 'EEXIST') throw error; - if (performance.now() > deadline) throw fault('FILE_BUSY', '文件正被其他操作占用;若进程异常退出,请核对锁文件: ' + lock); - await delay(30); - } - } - } - return await action(); - } finally { for (const lock of held.reverse()) await fs.rm(lock, { force: true }); } - } - /** 取得文件修改前指纹,不把目录或特殊设备当作普通文件写入。 */ async function previous(file, context) { const stat = await statMaybe(file); @@ -118,6 +87,8 @@ function createFiles(encryption, options = {}) { let backedUp = false; let installed = false; let retainBackup = false; + let outcome; + let failure; try { const prepared = await encryption.prepare(stage, writer, expected, decision, options); // Windows FlushFileBuffers需要可写句柄,刷盘后再恢复原权限。 @@ -131,7 +102,7 @@ function createFiles(encryption, options = {}) { installed = true; const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' : decision.mode, options); if (backedUp) { await fs.rm(backup); backedUp = false; } - return { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verified.diskState === 'unknown' ? ['内容已校验,但没有独立读取器证明磁盘为明文'] : [] }; + outcome = { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verified.diskState === 'unknown' ? ['内容已校验,但没有独立读取器证明磁盘为明文'] : [] }; } catch (error) { // 回滚不受已取消的请求预算影响,优先恢复原始文件。 try { @@ -140,15 +111,15 @@ function createFiles(encryption, options = {}) { error.changed = false; } catch (rollbackError) { retainBackup = backedUp; - error.changed = installed; + // 即使新文件尚未安装,原目标已经移入备份也属于状态改变。 + error.changed = installed || backedUp; error.recoveryPath = backedUp ? backup : null; error.rollbackError = rollbackError.message; } - throw error; - } finally { - await fs.rm(stage, { force: true }); - if (!retainBackup && backedUp) await fs.rm(backup, { force: true }); + failure = error; } + const cleanupErrors = await cleanupFiles([stage, ...(!retainBackup && backedUp ? [backup] : [])]); + return finishCleanup(outcome, failure, cleanupErrors); } /** 提交文本载荷,写入者使用独占创建防止临时名冲突。 */ @@ -178,11 +149,18 @@ function createFiles(encryption, options = {}) { destination = await resolve(destination, { write: true }); if (pathKey(source) === pathKey(destination)) return { changed: false, sameFile: true, source, destination }; if (sourceStat.isDirectory() && inside(source, destination)) throw fault('RECURSIVE_TARGET', '目标不能位于源目录内部'); + if (sourceStat.isDirectory() && inside(destination, source)) throw fault('OVERLAPPING_PATHS', '最终目标不能是源目录的祖先;请先复制到独立目录'); return locked([source, destination], async () => { const completed = []; const createdDirectories = []; const sourceDirectories = []; let removedSources = 0; + const removedSourcePaths = []; + /** 文件和目录仅在真实删除成功后计数,保留有界的源变化清单。 */ + function recordSourceRemoval(file) { + removedSources++; + if (removedSourcePaths.length < 100) removedSourcePaths.push(file); + } /** 逐项遍历,不跟随符号链接,避免跨根和意外递归。 */ async function visit(src, dst) { checkBudget(settings.signal, settings.deadline); @@ -198,7 +176,13 @@ function createFiles(encryption, options = {}) { if (!old) { await fs.mkdir(dst, { recursive: true }); createdDirectories.push(dst); } for (const entry of await fs.readdir(src)) await visit(path.join(src, entry), path.join(dst, entry)); } else if (st.isFile()) { - const result = await copyOne(src, dst, settings); + let result; + try { result = await copyOne(src, dst, settings); } + catch (error) { + // 当前失败文件尚未加入completed;保留它的恢复状态和实际目标路径。 + if (error.changed || error.recoveryPath) error.partial = [...(error.partial || []), dst]; + throw error; + } if (move && result.sameFile) throw fault('SAME_FILE_ALIAS', '源与目标为同一文件的别名,拒绝删源'); completed.push({ source: src, destination: dst, ...result }); } else throw fault('SPECIAL_FILE', '不支持复制特殊设备文件'); @@ -215,30 +199,73 @@ function createFiles(encryption, options = {}) { for (const item of completed) { if ((await fingerprint(item.source, settings)).hash !== item.sourceHash) throw fault('CONFLICT', '源文件在删源前被修改,已保留'); await fs.unlink(item.source); - removedSources++; + recordSourceRemoval(item.source); + } + for (const dir of sourceDirectories.reverse()) { + checkBudget(settings.signal, settings.deadline); + await fs.rmdir(dir); + recordSourceRemoval(dir); } - for (const dir of sourceDirectories.reverse()) await fs.rmdir(dir); } - return { changed: move || completed.some(x => x.changed) || createdDirectories.length > 0, source, destination, files: completed.length, moved: move, warnings: completed.flatMap(x => x.warnings || []).slice(0, 10) }; + const cleanupErrors = completed.flatMap(x => x.cleanupErrors || []).slice(0, 100); + return { changed: move || completed.some(x => x.changed) || createdDirectories.length > 0, source, destination, files: completed.length, moved: move, ...(cleanupErrors.length ? { cleanupErrors } : {}), warnings: completed.flatMap(x => x.warnings || []).slice(0, 10) }; } catch (error) { - error.changed = completed.some(x => x.changed) || createdDirectories.length > 0; - error.partial = completed.map(x => x.destination).slice(0, 100); + error.changed = !!error.changed || removedSources > 0 || completed.some(x => x.changed) || createdDirectories.length > 0; + error.partial = [...new Set([...(error.partial || []), ...completed.map(x => x.destination)])].slice(0, 100); error.sourceRetained = removedSources === 0; + if (move) { + error.removedSourceCount = removedSources; + error.removedSourcePaths = removedSourcePaths; + error.removedSourcePathsTruncated = removedSources > removedSourcePaths.length; + } + const cleanupErrors = [...completed.flatMap(x => x.cleanupErrors || []), ...(error.cleanupErrors || [])].slice(0, 100); + if (cleanupErrors.length) error.cleanupErrors = cleanupErrors; throw error; } }, settings); } - /** 删除单个文件、链接或目录;空目录非递归删除使用 rmdir。 */ + /** 逐项删除并记录实际完成项,部分失败不再报告为未修改。 */ async function remove(value, settings = {}) { if (!allowDelete) throw fault('DELETE_DISABLED', '服务已禁用删除工具'); const file = await resolve(value, { write: !settings.dryRun, leaf: true, destructive: true }); return locked([file], async () => { const st = await fs.lstat(file); if (settings.dryRun) return { changed: false, dryRun: true, path: file, type: st.isDirectory() ? 'directory' : st.isSymbolicLink() ? 'symlink' : 'file' }; - if (st.isDirectory() && settings.recursive === false) await fs.rmdir(file); - else await fs.rm(file, { recursive: st.isDirectory(), force: false }); - return { changed: true, path: file }; + const removed = []; + let removedCount = 0; + let visited = 0; + let failedPath = file; + /** 不跟随链接,逐层检查预算;只在系统删除成功后计数。 */ + async function erase(target, depth = 0) { + failedPath = target; + checkBudget(settings.signal, settings.deadline); + if (++visited > 10000) throw fault('ITEM_LIMIT', '删除超过一万项预算'); + if (depth > 128) throw fault('DEPTH_LIMIT', '删除目录深度超过128层'); + await resolve(target, { write: true, leaf: true, destructive: true }); + const current = await fs.lstat(target); + if (current.isDirectory()) { + if (settings.recursive !== false) { + const handle = await fs.opendir(target); + for await (const entry of handle) await erase(path.join(target, entry.name), depth + 1); + } + failedPath = target; + checkBudget(settings.signal, settings.deadline); + await fs.rmdir(target); + } else await fs.unlink(target); + removedCount++; + if (removed.length < 100) removed.push(target); + } + try { await erase(file); } + catch (error) { + error.changed = removedCount > 0; + error.partial = removed; + error.removedCount = removedCount; + error.partialTruncated = removedCount > removed.length; + error.failedPath = failedPath; + throw error; + } + return { changed: true, path: file, removedCount }; }, settings); } diff --git a/lib/locks.js b/lib/locks.js new file mode 100644 index 0000000..d0b3325 --- /dev/null +++ b/lib/locks.js @@ -0,0 +1,88 @@ +/** 跨进程登记路径占用;祖先与后代互斥,无关路径可并行。 */ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { setTimeout: delay } = require('node:timers/promises'); +const { performance } = require('node:perf_hooks'); +const { fault, checkBudget } = require('./text'); +const { cleanupFiles, finishCleanup } = require('./cleanup'); + +/** 对Windows路径统一大小写,供路径关系和占用记录比较。 */ +function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); } + +/** 按路径段判断同路径或后代,避免相似前缀误判。 */ +function inside(base, candidate) { + const rel = path.relative(pathKey(base), pathKey(candidate)); + return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel)); +} + +/** 创建使用同一状态目录的跨实例锁管理器。 */ +function createLocker(stateDir) { + const dir = path.join(stateDir, '.mcp-file-locks'); + const guard = path.join(dir, '.registry-guard'); + + /** 在短临界区内检查并原子登记整组路径,等待期间不持有其他路径锁。 */ + async function locked(files, action, context = {}) { + await fs.mkdir(dir, { recursive: true }); + const wanted = [...new Set(files.map(pathKey))].sort(); + const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000); + let lease = null; + let value; + let failure; + try { + while (!lease) { + checkBudget(context.signal, context.deadline); + if (performance.now() > deadline) throw fault('FILE_BUSY', '路径或其子树正被其他操作占用;异常退出后请核对锁目录: ' + dir); + let guarding = false; + let attemptError; + try { + const handle = await fs.open(guard, 'wx', 0o600); + guarding = true; + try { await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); } + finally { await handle.close(); } + let conflict = false; + for (const entry of await fs.readdir(dir)) { + checkBudget(context.signal, context.deadline); + if (!entry.endsWith('.lock')) continue; + let record; + try { record = JSON.parse(await fs.readFile(path.join(dir, entry), 'utf8')); } + catch (error) { + // 持有者可在临界区外释放记录;已删除的记录不再冲突。 + if (error.code === 'ENOENT') continue; + throw fault('FILE_BUSY', '锁记录不可读取,请核对后处理: ' + path.join(dir, entry)); + } + const occupied = record?.files || (record?.file ? [record.file] : null); + if (!Array.isArray(occupied) || !occupied.length || !occupied.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('FILE_BUSY', '锁记录无效,请核对: ' + path.join(dir, entry)); + if (wanted.some(a => occupied.some(b => inside(a, b) || inside(b, a)))) { conflict = true; break; } + } + if (!conflict) { + const candidate = path.join(dir, crypto.randomUUID() + '.lock'); + const handle = await fs.open(candidate, 'wx', 0o600); + lease = candidate; + try { await handle.writeFile(JSON.stringify({ pid: process.pid, files: wanted, createdAt: new Date().toISOString() })); } + finally { await handle.close(); } + } + } catch (error) { + if (guarding || error.code !== 'EEXIST') attemptError = error; + } finally { + if (guarding) { + try { await fs.rm(guard, { force: true }); } + catch (error) { + const diagnostic = { path: guard, code: error.code, message: error.message }; + if (!attemptError) attemptError = fault('LOCK_CLEANUP_FAILED', '锁登记临界区无法释放,请核对: ' + guard); + attemptError.cleanupErrors = [...(attemptError.cleanupErrors || []), diagnostic]; + } + } + } + if (attemptError) throw attemptError; + if (!lease) await delay(30); + } + checkBudget(context.signal, context.deadline); + value = await action(); + } catch (error) { failure = error; } + return finishCleanup(value, failure, await cleanupFiles(lease ? [lease] : [])); + } + return locked; +} + +module.exports = { createLocker, pathKey, inside }; diff --git a/lib/patterns.js b/lib/patterns.js index 4a0cfbe..00fc97c 100644 --- a/lib/patterns.js +++ b/lib/patterns.js @@ -1,5 +1,9 @@ /** glob 解析及保持原始位置的字符串编辑。 */ -const { fault, normalizeEol, detectEol } = require('./text'); +const { setImmediate: yieldLoop } = require('node:timers/promises'); +const { fault, normalizeEol, detectEol, EDIT_BYTES, checkBudget } = require('./text'); +const GLOB_COMPILE_LIMIT = 100000; +const GLOB_WORK_LIMIT = 50000000; +const GLOB_YIELD_INTERVAL = 16384; /** 按顶层逗号分隔旧版模式列表,保留花括号内的逗号。 */ function splitPatterns(value) { @@ -65,6 +69,59 @@ function globMatcher(patterns) { return relative => !matchers.length || matchers.some(m => m.alternatives.some(tokens => matchTokens(tokens, m.basename ? relative.split('/').at(-1) : relative))); } +/** 为一次完整扫描累计编译和匹配工作量,定期让出主线程处理心跳/取消。 */ +function globBudget(options) { + let work = 0; + let sinceYield = 0; + /** 先扣减总预算再执行对应计算,返回是否需要让出事件循环。 */ + function spend(units) { + checkBudget(options.signal, options.deadline); + work += units; + sinceYield += units; + if (work > GLOB_WORK_LIMIT) throw fault('GLOB_LIMIT', '本次扫描的glob累计计算超过预算,请缩小模式或扫描范围'); + return sinceYield >= GLOB_YIELD_INTERVAL; + } + /** 允许其他MCP请求及取消事件运行,恢复后再次检查绝对截止时间。 */ + async function pause() { + await yieldLoop(); + sinceYield = 0; + checkBudget(options.signal, options.deadline); + } + return { spend, pause }; +} + +/** 生产扫描使用异步glob;预算贯穿编译、全部分支及所有路径,不随文件重置。 */ +async function globMatcherAsync(patterns, options = {}) { + const budget = globBudget(options); + const matchers = []; + let compiled = 0; + budget.spend(0); + for (const pattern of new Set(splitPatterns(patterns))) { + if (pattern.length > 1024) throw fault('INVALID_GLOB', 'glob模式过长'); + const normalized = pattern.replace(/\\/g, '/'); + if (budget.spend(normalized.length)) await budget.pause(); + const alternatives = []; + for (const expanded of new Set(expandBraces(normalized))) { + compiled += expanded.length; + if (compiled > GLOB_COMPILE_LIMIT) throw fault('GLOB_LIMIT', 'glob展开后的累计长度超过10万,请减少模式与分支'); + if (budget.spend(expanded.length)) await budget.pause(); + alternatives.push(tokenizeGlob(expanded)); + } + matchers.push({ basename: !normalized.includes('/'), alternatives }); + } + /** 每次匹配继续消耗同一扫描的预算,并保持已有basename/相对路径语义。 */ + return async function matches(relative) { + budget.spend(0); + const basename = relative.split('/').at(-1); + for (const matcher of matchers) { + for (const tokens of matcher.alternatives) { + if (await matchTokensAsync(tokens, matcher.basename ? basename : relative, budget)) return true; + } + } + return !matchers.length; + }; +} + /** 有上限地展开花括号;不将用户模式交给可能回溯的JS正则。 */ function expandBraces(pattern, depth = 0) { if (depth > 8) throw fault('INVALID_GLOB', 'glob嵌套超过上限'); @@ -99,20 +156,35 @@ function tokenizeGlob(pattern) { return tokens; } -/** 动态规划匹配glob,复杂度有明确上限,不存在灾难性回溯。 */ +/** 计算一个token的动态规划行,供同步对照和异步生产匹配共用。 */ +function advanceToken(previous, token, value) { + const current = new Uint8Array(value.length + 1); + let reachable = false; + for (let j = 0; j <= value.length; j++) { + if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0; + else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; } + else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0; + } + return current; +} + +/** 同步匹配保留给兼容调用与语义对照,生产工具使用异步入口。 */ function matchTokens(tokens, value) { + if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算'); + let previous = new Uint8Array(value.length + 1); + previous[0] = 1; + for (const token of tokens) previous = advanceToken(previous, token, value); + return !!previous[value.length]; +} + +/** 逐行计算并共享请求总预算;一次同步片段最多一行加一个让出间隔。 */ +async function matchTokensAsync(tokens, value, budget) { if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算'); let previous = new Uint8Array(value.length + 1); previous[0] = 1; for (const token of tokens) { - const current = new Uint8Array(value.length + 1); - let reachable = false; - for (let j = 0; j <= value.length; j++) { - if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0; - else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; } - else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0; - } - previous = current; + if (budget.spend(value.length + 1)) await budget.pause(); + previous = advanceToken(previous, token, value); } return !!previous[value.length]; } @@ -163,13 +235,26 @@ function applyLiteral(original, edit, ignoreCase = false) { if (!matches.count) throw fault('NO_MATCH', '未找到匹配内容;请检查空格、缩进和原文版本'); if (edit.expectedMatches !== undefined && matches.count !== edit.expectedMatches) throw fault('MATCH_COUNT_MISMATCH', '实际匹配数与 expectedMatches 不一致', { matched: matches.count }); const chosen = matches.selected; - let updated = original; - for (const match of chosen.reverse()) { + const spans = []; + let bytes = Buffer.byteLength(original); + const replacementBytes = Buffer.byteLength(replacement); + for (const match of chosen) { const start = map ? map(match.index) : match.index; const end = map ? map(match.index + match[0].length) : match.index + match[0].length; - updated = updated.slice(0, start) + replacement + updated.slice(end); + bytes += replacementBytes - Buffer.byteLength(original.slice(start, end)); + spans.push({ start, end }); + } + if (bytes > EDIT_BYTES) throw fault('FILE_TOO_LARGE', '编辑结果超过16MB预算'); + // 预先核对输出体量,再按原文位置一次拼接,避免逐匹配复制整个文件。 + const parts = []; + let cursor = 0; + for (const { start, end } of spans) { + parts.push(original.slice(cursor, start), replacement); + cursor = end; } + parts.push(original.slice(cursor)); + const updated = parts.join(''); return { updated, matched: matches.count, replaced: chosen.length, eolAdapted: !!map }; } -module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, applyLiteral }; +module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, globMatcherAsync, applyLiteral }; diff --git a/lib/regex-worker.js b/lib/regex-worker.js index b05dda5..6d43a37 100644 --- a/lib/regex-worker.js +++ b/lib/regex-worker.js @@ -1,8 +1,10 @@ /** 将用户正则隔离在可终止线程,主线程保持 MCP 心跳响应。 */ const { parentPort } = require('node:worker_threads'); +const { applyLiteral } = require('./patterns'); /** 执行有限输出的匹配或编辑,超时由父线程终止整个 worker。 */ function execute(task) { + if (task.operation === 'literal_edit') return applyLiteral(task.text, task.edit, task.ignoreCase); const regex = new RegExp(task.pattern, 'gm' + (task.ignoreCase ? 'i' : '')); if (task.operation === 'edit') { let count = 0; @@ -39,5 +41,5 @@ function execute(task) { parentPort.on('message', message => { try { parentPort.postMessage({ id: message.id, result: execute(message.task) }); } - catch (error) { parentPort.postMessage({ id: message.id, error: error.message }); } + catch (error) { parentPort.postMessage({ id: message.id, error: { code: error.code || (message.task.operation === 'literal_edit' ? 'EDIT_FAILED' : 'INVALID_REGEX'), message: error.message } }); } }); diff --git a/lib/regex.js b/lib/regex.js index 25e64b6..3b93a42 100644 --- a/lib/regex.js +++ b/lib/regex.js @@ -1,7 +1,8 @@ -/** 复用正则 worker;每个调用独立截止,超时后重建线程。 */ +/** 正则与字面量编辑共用有界计算线程;主线程负责取消和截止时间。 */ const { Worker } = require('node:worker_threads'); const path = require('node:path'); -const { fault } = require('./text'); +const { performance } = require('node:perf_hooks'); +const { fault, checkBudget } = require('./text'); let worker = null; let serial = 0; const pending = new Map(); @@ -26,7 +27,8 @@ function ensureWorker() { pending.delete(message.id); clearTimeout(job.timer); job.cleanup(); - if (message.error) job.reject(fault('INVALID_REGEX', message.error)); + if (performance.now() > job.deadline) job.reject(fault('TIMEOUT', '计算结果到达时已超过操作预算')); + else if (message.error) job.reject(fault(message.error.code, message.error.message)); else job.resolve(message.result); if (!pending.size) current.unref(); }); @@ -35,21 +37,33 @@ function ensureWorker() { return current; } -/** 运行有硬超时和队列上限的正则任务。 */ -function runRegex(task, options = {}) { - if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符')); +/** 分派有硬超时和队列上限的计算任务,连同排队时间一起计入预算。 */ +function runTask(task, options = {}) { if (pending.size >= 16) return Promise.reject(fault('BUSY', '正则队列已满,请缩小搜索范围')); if (options.signal?.aborted) return Promise.reject(fault('CANCELLED', '操作已取消')); return new Promise((resolve, reject) => { + checkBudget(options.signal, options.deadline); const current = ensureWorker(); current.ref(); const id = ++serial; const abort = () => stop(fault('CANCELLED', '正则任务已取消')); - const timer = setTimeout(() => stop(fault('REGEX_TIMEOUT', '正则计算超过预算;请简化正则或使用字面量模式')), options.timeoutMs ?? 1000); + const deadline = Math.min(options.deadline ?? Infinity, performance.now() + (options.timeoutMs ?? 1000)); + const timer = setTimeout(() => stop(task.operation === 'literal_edit' ? fault('TIMEOUT', '字面量编辑超过时间预算') : fault('REGEX_TIMEOUT', '正则计算超过预算;请简化正则或使用字面量模式')), Math.max(1, deadline - performance.now())); options.signal?.addEventListener('abort', abort, { once: true }); - pending.set(id, { resolve, reject, timer, cleanup: () => options.signal?.removeEventListener('abort', abort) }); + pending.set(id, { resolve, reject, timer, deadline, cleanup: () => options.signal?.removeEventListener('abort', abort) }); current.postMessage({ id, task }); }); } -module.exports = { runRegex, stop }; +/** 限制用户正则长度后交给工作线程执行。 */ +function runRegex(task, options = {}) { + if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符')); + return runTask(task, options); +} + +/** 字面量保留原始索引、换行和替换语义,同样具备线程取消能力。 */ +function runLiteral(text, edit, ignoreCase, options = {}) { + return runTask({ operation: 'literal_edit', text, edit, ignoreCase }, options); +} + +module.exports = { runRegex, runLiteral, stop }; diff --git a/lib/server.js b/lib/server.js index a5e82cc..dd322eb 100644 --- a/lib/server.js +++ b/lib/server.js @@ -6,8 +6,8 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); const { z } = require('zod'); const pkg = require('../package.json'); const text = require('./text'); -const { globMatcher, applyLiteral, escapeRegex } = require('./patterns'); -const { runRegex } = require('./regex'); +const { globMatcherAsync, escapeRegex } = require('./patterns'); +const { runRegex, runLiteral } = require('./regex'); const { createEncryption } = require('./encryption'); const { createFiles, statMaybe } = require('./files'); const IGNORE = new Set(['node_modules', '.git', 'target', 'build', 'dist', '.idea', '.vscode', '.svn', 'bin', 'obj', 'out', 'vendor']); @@ -52,9 +52,8 @@ function createServer(options = {}) { return await action(args, context); } catch (error) { return result('操作失败:' + error.message, { changed: !!error.changed, - ...(error.recoveryPath ? { recoveryPath: error.recoveryPath, rollbackError: error.rollbackError } : {}), - ...(error.partial ? { partial: error.partial, sourceRetained: error.sourceRetained } : {}), - }, { ok: false, code: error.code || 'INTERNAL_ERROR' }); + ...Object.fromEntries(['recoveryPath', 'rollbackError', 'partial', 'sourceRetained', 'removedSourceCount', 'removedSourcePaths', 'removedSourcePathsTruncated', 'removedCount', 'partialTruncated', 'failedPath', 'cleanupErrors'].filter(key => error[key] !== undefined).map(key => [key, error[key]])), + }, { ok: false, code: error.code || 'INTERNAL_ERROR', warnings: error.cleanupErrors?.length ? ['部分临时文件未能清理,详见cleanupErrors'] : [] }); } }; handlers[name] = handler; @@ -127,7 +126,9 @@ function createServer(options = {}) { let matched = 0; for (const item of args.edits || [args]) { text.checkBudget(context.signal, context.deadline); - const change = args.useRegex ? await runRegex({ operation: 'edit', text: updated, pattern: item.oldString, replacement: text.normalizeEol(item.newString, text.detectEol(updated)), replaceAll: item.replaceAll === true, ignoreCase: args.ignoreCase }, { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) }) : applyLiteral(updated, item, args.ignoreCase); + const computeBudget = { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) }; + const change = args.useRegex ? await runRegex({ operation: 'edit', text: updated, pattern: item.oldString, replacement: text.normalizeEol(item.newString, text.detectEol(updated)), replaceAll: item.replaceAll === true, ignoreCase: args.ignoreCase }, computeBudget) : await runLiteral(updated, item, args.ignoreCase, computeBudget); + text.checkBudget(context.signal, context.deadline); if (!change.matched) throw text.fault('NO_MATCH', '未找到匹配内容,文件未修改'); if (item.expectedMatches !== undefined && item.expectedMatches !== change.matched) throw text.fault('MATCH_COUNT_MISMATCH', '实际匹配数与expectedMatches不一致'); updated = change.updated; @@ -137,6 +138,7 @@ function createServer(options = {}) { } const payload = (original.hasBom ? '\uFEFF' : '') + updated; const details = { matched, replaced, originalHash: before.hash, proposedHash: text.payloadFingerprint(payload).hash, preview: preview(original.content, updated) }; + text.checkBudget(context.signal, context.deadline); if (args.dryRun) return result('编辑预览完成,文件未修改', { changed: false, dryRun: true, ...details }); const data = await files.write(file, payload, { ...context, expectedHash: before.hash, writePolicy: args.writePolicy }); return result('编辑成功: ' + file + '\n实际替换 ' + replaced + ' 处,共匹配 ' + matched + ' 处', { ...data, ...details }, { warnings: data.warnings || [] }); @@ -176,7 +178,7 @@ function createServer(options = {}) { ...scanShape, pattern: z.string().max(4096), include: patternsSchema, ignoreCase: z.boolean().optional(), onlyMatching: z.boolean().optional(), mode: z.enum(['regex', 'literal']).optional(), contextLines: z.number().int().min(0).max(10).optional(), }, true, async (args, context) => { const root = await files.resolve(args.path); - const include = globMatcher(args.include); + const include = await globMatcherAsync(args.include, context); const errors = []; const skipped = { binary: 0, large: 0, unreadable: 0 }; const results = []; @@ -186,7 +188,7 @@ function createServer(options = {}) { let truncated = false; const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean); for await (const entry of walk(root, { ...args, exclude }, errors, context)) { - if (entry.type !== 'file' || !include(entry.relative)) continue; + if (entry.type !== 'file' || !(await include(entry.relative))) continue; if (results.length >= limit || budget <= 0) { truncated = true; break; } let content; try { @@ -211,23 +213,25 @@ function createServer(options = {}) { } if (found.truncated || truncated) { truncated = true; break; } } + text.checkBudget(context.signal, context.deadline); const warnings = errors.length ? ['部分文件/目录未能扫描,详见errors和skipped;无匹配不代表这些文件没有内容'] : []; return result('找到 ' + results.length + ' 处匹配(扫描 ' + scanned + ' 个文件)\n' + results.map(hit => hit.path + ':' + hit.line + ':' + hit.text).join('\n') + (truncated ? '\n结果已截断,请缩小范围' : ''), { results, scanned, skipped, errors, truncated }, { warnings, ok: scanned > 0 || !errors.length, code: scanned === 0 && errors.length ? 'SCAN_INCOMPLETE' : 'OK' }); }); register('find_files', '按glob递归查找,支持花括号、字面括号路径、隐藏项及默认忽略开关。', { ...scanShape, pattern: z.string().min(1).max(1024) }, true, async (args, context) => { const root = await files.resolve(args.path); - const matches = globMatcher([args.pattern]); + const matches = await globMatcherAsync([args.pattern], context); const entries = []; const errors = []; let truncated = false; let budget = text.READ_LIMIT; const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean); for await (const entry of walk(root, { ...args, exclude }, errors, context)) { - if (!matches(entry.relative)) continue; + if (!(await matches(entry.relative))) continue; budget -= entry.file.length + entry.relative.length + 64; if (entries.length >= (args.maxResults ?? 500) || budget < 0) { truncated = true; break; } entries.push(entry); } + text.checkBudget(context.signal, context.deadline); return result(entries.map(entry => entry.type.toUpperCase() + ' ' + entry.file).join('\n') || '未找到匹配项', { entries, errors, truncated }, { warnings: errors.length ? ['部分目录未能扫描'] : [], ok: !!entries.length || !errors.length, code: !entries.length && errors.length ? 'SCAN_INCOMPLETE' : 'OK' }); }); register('list_directory', '列目录的文件/目录/链接类型与元数据,支持分页。', { path: pathSchema, showHidden: z.boolean().optional(), offset: z.number().int().min(0).max(1000000).optional(), maxResults: z.number().int().min(1).max(2000).optional() }, true, async (args, context) => { @@ -257,11 +261,13 @@ function createServer(options = {}) { if (st.isFile() && args.calculateHash !== false) { const fp = await text.fingerprint(file, context); data.sizeReadable = fp.size; data.sizePlaintext = fp.size; data.hash = fp.hash; } return result(JSON.stringify(data, null, 2), data); }); - register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async args => { + register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async (args, context) => { const dir = await files.resolve(args.path, { write: true }); - const existed = !!(await statMaybe(dir)); - await fs.mkdir(dir, { recursive: true }); - return result('目录已创建: ' + dir, { path: dir, changed: !existed }); + return files.locked([dir], async () => { + const existed = !!(await statMaybe(dir)); + await fs.mkdir(dir, { recursive: true }); + return result('目录已创建: ' + dir, { path: dir, changed: !existed }); + }, context); }); const transferShape = { source: pathSchema, destination: pathSchema, overwrite: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema }; register('copy_path', '逐文件复制和验证。目标已有目录时放入其下;失败返回部分目标并保留源,不自动跟随链接。', transferShape, false, async (args, context) => { @@ -306,8 +312,10 @@ function createServer(options = {}) { }); register('inspect_write_strategy', '检查有效写入策略;在已存在目标目录创建并清理随机探测样本,不修改目标文件。', { path: pathSchema, writePolicy: policySchema, timeoutMs: timeoutSchema }, false, async (args, context) => { const file = await files.resolve(args.path); - const data = await encryption.strategy(file, args.writePolicy, context); - return result(JSON.stringify(data, null, 2), data); + return files.locked([path.dirname(file)], async () => { + const data = await encryption.strategy(file, args.writePolicy, context); + return result(JSON.stringify(data, null, 2), data); + }, context); }); return { server, handlers, encryption, files }; } diff --git a/lib/text.js b/lib/text.js index d9f796c..8b2a0b1 100644 --- a/lib/text.js +++ b/lib/text.js @@ -117,7 +117,7 @@ async function* textLines(file, options = {}) { else if (pending) yield pending; } -/** 按行分页,达到输出预算即停,不为统计总行数读取整文件。 */ +/** 按行分页,到达请求结束行即停;未探测EOF时nextLine为待确认的续读位置。 */ async function readLines(file, start, end, options = {}) { const lines = []; let lineNo = 0; @@ -126,9 +126,15 @@ async function readLines(file, start, end, options = {}) { for await (const line of textLines(file, options)) { lineNo++; if (lineNo < start) continue; - if (lineNo > end || chars + line.length + 16 > READ_LIMIT) { nextLine = lineNo; break; } + if (chars + line.length + 16 > READ_LIMIT) { + if (!lines.length) throw fault('LINE_TOO_LONG', '该行加上行号后超过单页预算,请使用字符分页'); + nextLine = lineNo; + break; + } lines.push({ line: lineNo, text: line }); chars += line.length + 16; + // 不请求迭代器生成下一整行,否则范围外的长行也会让当前页失败。 + if (lineNo === end) { nextLine = lineNo + 1; break; } } if (lineNo === 0 && start === 1) { lines.push({ line: 1, text: '' }); lineNo = 1; } if (!lines.length && start > lineNo) throw fault('LINE_OUT_OF_RANGE', '起始行超出已读取文件的总行数'); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c6f3726 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1186 @@ +{ + "name": "mcp-read-file-server", + "version": "1.9.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-read-file-server", + "version": "1.9.3", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "zod": "4.4.3" + }, + "bin": { + "mcp-read-file-server": "index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json index e398cd8..2725e50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-read-file-server", - "version": "1.9.0", + "version": "1.9.3", "description": "加密环境文件操作 MCP 工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,适用于任何支持 MCP 协议的 Agent。", "type": "commonjs", "main": "index.js", @@ -16,7 +16,7 @@ ], "scripts": { "start": "node index.js", - "test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js", + "test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js test/resilience.test.js test/followup-fixes.test.js", "check": "node scripts/check.js" }, "keywords": [ @@ -52,6 +52,7 @@ }, "overrides": { "fast-uri": "^3.1.6", - "qs": "^6.16.0" + "qs": "^6.16.0", + "hono": "4.13.5" } } diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..226c9d1 --- /dev/null +++ b/test/README.md @@ -0,0 +1,15 @@ +# 回归测试 + +用途:MCP 1.9.0回归、真实stdio和Windows适配器验收源码。创建日期2026-09-08,关联全面审查修复任务。 + +仅使用Node内置test runner和项目已有MCP SDK,不安装测试框架。设置MCP_TEST_ROOT到专用D盘测试目录后执行npm test。helper.cjs为每个测试创建独立profile/文件样本,并在结束时先关闭子进程,再校验路径和删除样本。 + +regression.test.js执行原审查正确行为断言及注入故障验证;protocol.test.js使用真实SDK/stdio;adapters.test.js在Windows上测试实际外部复制进程。其他系统跳过Windows专用用例,此跳过不代表Windows通过。 + +测试文件属于仓库,不随npm运行包发布。无需卸载依赖;不要删除测试以使CI通过。真实TSD、断电及实际跨盘仍需专门环境验收。 + +2026-09-09 / 1.9.1:新增review-fixes.test.js,覆盖目录双向重叠、安装前/后回滚失败、部分删源状态、行分页预算边界及范围外长行;沿用Node内置test runner,执行npm test即可,无新增测试依赖。所有故障注入仅作用于独立测试样本。 + +2026-09-09 / 1.9.2:新增resilience.test.js,覆盖跨实例/跨进程目录与子项锁、无关路径并行、复合清理故障、递归删除的部分失败/取消、Windows真实占用、字面量输出预算与真实stdio心跳。仍使用已有Node/SDK,无新增依赖;运行npm test,测试源随仓库保留,无单独卸载步骤。 + +2026-09-10 / 1.9.3:新增followup-fixes.test.js,覆盖真实目录别名的工作根保护、空目录移动失败计数、探测与移动互斥、失败后的历史清理诊断,以及glob累计预算/取消/语义和真实stdio心跳。使用现有Node测试运行器,执行npm test,样本隔离到MCP_TEST_ROOT并自动清理;没有新依赖或安装工具。 diff --git a/test/adapters.test.js b/test/adapters.test.js new file mode 100644 index 0000000..6703707 --- /dev/null +++ b/test/adapters.test.js @@ -0,0 +1,23 @@ +/** 实际Windows外部进程适配器验收;不依赖或更改真实加密profile。 */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { fixture } = require('./helper.cjs'); +const { externalCopy, inspectDisk } = require('../lib/encryption'); +const { fingerprint } = require('../lib/text'); + +test('实际Windows适配器:PowerShell/cmd/robocopy/cscript复制特殊路径与二进制', { skip: process.platform !== 'win32' }, async t => { + const f = await fixture(t); + const source = await f.sample("带 空格 ' %PATH%.tmp", Buffer.from([0, 1, 255, 26, 13, 10, 65])); + for (const proc of ['powershell', 'cmd', 'robocopy', 'cscript']) { + await t.test(proc, async () => { + const destination = path.join(f.root, proc + " 输出 ' %PATH%.dat"); + await externalCopy(proc, source, destination); + assert.deepEqual(await fs.readFile(destination), await fs.readFile(source)); + const raw = await inspectDisk('powershell', destination); + assert.deepEqual(raw, await fingerprint(source)); + }); + } + assert.ok(!(await fs.readdir(f.root)).some(name => name.startsWith('.mcp-'))); +}); diff --git a/test/followup-fixes.test.js b/test/followup-fixes.test.js new file mode 100644 index 0000000..e83f94a --- /dev/null +++ b/test/followup-fixes.test.js @@ -0,0 +1,215 @@ +/** 子代理审查FS1—FS4及TP1的正确行为回归;全部样本和配置隔离。 */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); +const { fixture, cachedProfile } = require('./helper.cjs'); +const { createServer } = require('../lib/server'); +const { fingerprint, fault } = require('../lib/text'); +const { globMatcherAsync } = require('../lib/patterns'); + +/** 取得成功的结构化数据,失败时展示完整响应。 */ +function success(response) { assert.equal(response.structuredContent.ok, true, JSON.stringify(response)); return response.structuredContent.data; } +/** 核对业务错误后取得状态字段。 */ +function failure(response, code) { assert.equal(response.structuredContent.ok, false, JSON.stringify(response)); assert.equal(response.structuredContent.code, code); return response.structuredContent.data; } +/** 建立确定性的调度屏障,不改变文件操作返回值。 */ +function gate() { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } +/** 限制测试等待时间,错误时不让测试进程永久挂起。 */ +async function bounded(promise, ms = 4000) { + let timer; + try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('测试等待超时')), ms); })]); } + finally { clearTimeout(timer); } +} +/** 使用平台支持的目录链接,不申请额外权限或修改环境。 */ +async function directoryLink(target, alias) { await fs.symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); } + +test('FS1 工作根配置为目录别名时,同时拒绝删除或移动真实根与配置别名', async t => { + const f = await fixture(t); + const file = await f.sample('actual/keep.txt', 'WORKSPACE-CONTENT'); + const actual = path.dirname(file), alias = path.join(f.root, 'alias'); + await directoryLink(actual, alias); + const app = createServer({ ...f.opts, encryption: f.encryption, baseDir: alias, allowedRoots: [] }); + for (const root of [actual, alias]) { + failure(await app.handlers.remove_path({ path: root }), 'PROTECTED_ROOT'); + failure(await app.handlers.move_path({ source: root, destination: path.join(f.root, 'moved') }), 'PROTECTED_ROOT'); + } + assert.equal(await fs.readFile(file, 'utf8'), 'WORKSPACE-CONTENT'); + await assert.rejects(fs.access(path.join(f.root, 'moved')), { code: 'ENOENT' }); +}); + +test('FS1 别名位于工作根祖先时仍保护真实根,普通链接可以单独删除', async t => { + const f = await fixture(t); + const file = await f.sample('actual/work/keep.txt', 'KEEP'); + const alias = path.join(f.root, 'alias'); + await directoryLink(path.join(f.root, 'actual'), alias); + const app = createServer({ ...f.opts, encryption: f.encryption, baseDir: path.join(alias, 'work'), allowedRoots: [] }); + failure(await app.handlers.remove_path({ path: path.dirname(file) }), 'PROTECTED_ROOT'); + failure(await app.handlers.move_path({ source: path.join(f.root, 'actual'), destination: path.join(f.root, 'moved-parent') }), 'PROTECTED_ROOT'); + failure(await app.handlers.remove_path({ path: path.join(f.root, 'actual') }), 'PROTECTED_ROOT'); + const target = await f.sample('separate/value.txt', 'SEPARATE'); + const childLink = path.join(path.dirname(file), 'link'); + await directoryLink(path.dirname(target), childLink); + success(await app.handlers.remove_path({ path: childLink })); + assert.equal(await fs.readFile(target, 'utf8'), 'SEPARATE'); + assert.equal(await fs.readFile(file, 'utf8'), 'KEEP'); +}); + +test('FS2 空目录移动部分删除失败,返回源变化计数及路径', async t => { + const f = await fixture(t); + const source = path.join(f.root, 'src'), target = path.join(f.root, 'out/src'); + for (const root of [source, target]) for (const name of ['a', 'b']) await fs.mkdir(path.join(root, name), { recursive: true }); + const realRmdir = fs.rmdir; + let removed, retained; + // 只让第二个源子目录删除失败,不依赖目录枚举顺序。 + fs.rmdir = async (file, ...args) => { + if (path.dirname(file) === source) { + if (removed) { retained = file; throw fault('EBUSY', '模拟第二个空目录占用'); } + await realRmdir(file, ...args); removed = file; return; + } + return realRmdir(file, ...args); + }; + let response; + try { response = await f.call('move_path', { source, destination: path.dirname(target), writePolicy: 'preserve' }); } + finally { fs.rmdir = realRmdir; } + const data = failure(response, 'EBUSY'); + assert.equal(data.changed, true); + assert.equal(data.sourceRetained, false); + assert.equal(data.removedSourceCount, 1); + assert.deepEqual(data.removedSourcePaths, [removed]); + assert.equal(data.removedSourcePathsTruncated, false); + await assert.rejects(fs.access(removed), { code: 'ENOENT' }); + await fs.access(retained); + for (const name of ['a', 'b']) await fs.access(path.join(target, name)); +}); + +test('FS2 未删除源项时失败仍报告未变化;成功移动空目录正常完成', async t => { + const f = await fixture(t); + const source = path.join(f.root, 'src'), target = path.join(f.root, 'out/src'); + await fs.mkdir(source); await fs.mkdir(target, { recursive: true }); + const realRmdir = fs.rmdir; + fs.rmdir = async (file, ...args) => { if (file === source) throw fault('EBUSY', '模拟源根占用'); return realRmdir(file, ...args); }; + let response; + try { response = await f.call('move_path', { source, destination: path.dirname(target) }); } + finally { fs.rmdir = realRmdir; } + const data = failure(response, 'EBUSY'); + assert.equal(data.changed, false); + assert.equal(data.sourceRetained, true); + assert.equal(data.removedSourceCount, 0); + success(await f.call('move_path', { source, destination: path.dirname(target) })); + await assert.rejects(fs.access(source), { code: 'ENOENT' }); + await fs.access(target); +}); + +test('FS3 策略探测完成并清理后才允许移动父目录,目标不留下探测文件', async t => { + const reached = gate(), release = gate(); + const f = await fixture(t, { + /** 在真实探测文件已生成后暂停独立读取结果,模拟正常异步I/O窗口。 */ + reader: async ({ file, context }) => { + const raw = await fingerprint(file, context); + if (path.basename(file).startsWith('.mcp-probe-')) { reached.resolve(file); await release.promise; } + return raw; + }, + }); + const file = await f.sample('src/data.txt', 'USER-CONTENT'); + const destination = path.join(f.root, 'dst'); + const inspecting = f.call('inspect_write_strategy', { path: path.join(path.dirname(file), 'new.txt') }); + let moving, settled = false; + try { + await bounded(reached.promise); + moving = f.call('move_path', { source: path.dirname(file), destination, writePolicy: 'preserve' }).then(value => { settled = true; return value; }); + await new Promise(resolve => setTimeout(resolve, 100)); + assert.equal(settled, false); + release.resolve(); + success(await inspecting); + assert.equal(success(await moving).files, 1); + assert.deepEqual(await fs.readdir(destination), ['data.txt']); + assert.equal(await fs.readFile(path.join(destination, 'data.txt'), 'utf8'), 'USER-CONTENT'); + } finally { release.resolve(); await inspecting; if (moving) await moving; } +}); + +test('FS4 目录复制后续失败时保留先前清理诊断及当前主错误', async t => { + const f = await fixture(t); + const a = await f.sample('src/a.txt', 'A'); + const b = await f.sample('src/b.txt', 'B'); + const destination = path.join(f.root, 'dst/src'); + await fs.mkdir(path.join(destination, 'b.txt'), { recursive: true }); + const realRm = fs.rm; + fs.rm = async (file, ...args) => { if (path.dirname(file) === destination && path.basename(file).startsWith('.mcp-stage-')) throw fault('EACCES', '模拟首项清理失败'); return realRm(file, ...args); }; + let response; + try { response = await f.call('copy_path', { source: path.dirname(a), destination: path.dirname(destination), writePolicy: 'preserve' }); } + finally { fs.rm = realRm; } + const data = failure(response, 'NOT_FILE'); + assert.equal(data.changed, true); + assert.deepEqual(data.partial, [path.join(destination, 'a.txt')]); + assert.equal(data.cleanupErrors.length, 1); + assert.equal(data.cleanupErrors[0].code, 'EACCES'); + assert.ok(response.structuredContent.warnings.length); + assert.equal(await fs.readFile(path.join(destination, 'a.txt'), 'utf8'), 'A'); + assert.equal(await fs.readFile(b, 'utf8'), 'B'); +}); + +/** 构造不含重复项、但每项仍满足schema的高复杂度模式。 */ +function uniquePatterns() { + return Array.from({ length: 100 }, (_, i) => '*a'.repeat(200) + '{' + Array.from({ length: 64 }, (_, j) => 'x' + j).join(',') + '}end' + i); +} + +test('TP1 异步glob保留花括号、basename与字面括号语义', async () => { + const cases = [ + ['*.{ts,tsx}', 'src/a.tsx', true], ['*.{ts,tsx}', 'src/a.js', false], + ['**/foo.js', 'foo.js', true], ['**/foo.js', 'a/foo.js', true], ['**/foo.js', 'notfoo.js', false], + ['app/(auth)/page.tsx', 'app/(auth)/page.tsx', true], ['app/(auth)/page.tsx', 'app/auth/page.tsx', false], + ['{src,{lib,test}}/*.js', 'test/a.js', true], [undefined, 'nested/file.txt', true], + ]; + for (const [pattern, file, expected] of cases) assert.equal(await (await globMatcherAsync(pattern))(file), expected); +}); + +test('TP1 glob总预算跨文件累计,计算过程中让出事件循环', async () => { + const matches = await globMatcherAsync('*a'.repeat(200) + 'x'); + let ticked = false; + const first = matches('a'.repeat(200) + '.txt'); + const timer = setImmediate(() => { ticked = true; }); + try { assert.equal(await first, false); assert.equal(ticked, true); } + finally { clearImmediate(timer); } + let processed = 1; + await assert.rejects(async () => { for (; processed < 1000; processed++) await matches('a'.repeat(200) + '.txt'); }, { code: 'GLOB_LIMIT' }); + assert.ok(processed > 1 && processed < 1000); +}); + +test('TP1 glob编译也受总预算、截止时间和取消约束', async () => { + await assert.rejects(globMatcherAsync(uniquePatterns()), { code: 'GLOB_LIMIT' }); + await assert.rejects(globMatcherAsync('*.js', { deadline: performance.now() - 1 }), { code: 'TIMEOUT' }); + const controller = new AbortController(); + const compiling = globMatcherAsync(uniquePatterns(), { signal: controller.signal }); + controller.abort(); + await assert.rejects(compiling, { code: 'CANCELLED' }); + const matches = await globMatcherAsync('*.txt', { signal: controller.signal }).catch(error => error); + assert.equal(matches.code, 'CANCELLED'); +}); + +test('TP1 真实stdio复杂include保持心跳响应,重复模式优化且过量编译明确失败', async t => { + const f = await fixture(t); + const file = await f.sample('samples/' + 'a'.repeat(200) + '.txt', 'hello'); + await fs.writeFile(f.encryption.cachePath, JSON.stringify(cachedProfile())); + const client = new Client({ name: 'glob-regression', version: '1.0.0' }); + const transport = new StdioClientTransport({ command: process.execPath, args: [path.resolve(__dirname, '../index.js')], cwd: f.root, stderr: 'pipe', env: { ...process.env, MCP_PROFILE_DIR: f.stateDir, MCP_BASE_DIR: f.root, MCP_ALLOWED_ROOTS: JSON.stringify([f.root]) } }); + await client.connect(transport); + f.cleanupTasks.push(() => client.close()); + const include = Array(100).fill('*a'.repeat(200) + '{' + Array(64).fill('x').join(',') + '}'); + const start = performance.now(); + const pending = client.callTool({ name: 'search_files', arguments: { path: path.dirname(file), pattern: 'hello', mode: 'literal', include, timeoutMs: 300 } }).then(response => ({ response, elapsedMs: performance.now() - start })); + await new Promise(resolve => setTimeout(resolve, 10)); + const pulse = performance.now(); + success(await client.callTool({ name: 'check_status', arguments: {} })); + const heartbeatMs = performance.now() - pulse; + const result = await bounded(pending); + assert.equal(success(result.response).results.length, 0); + assert.ok(result.elapsedMs < 800, '查询耗时=' + result.elapsedMs); + assert.ok(heartbeatMs < 500, '心跳耗时=' + heartbeatMs); + const limited = await client.callTool({ name: 'search_files', arguments: { path: path.dirname(file), pattern: 'hello', include: uniquePatterns(), timeoutMs: 3000 } }); + failure(limited, 'GLOB_LIMIT'); + assert.equal(success(await client.callTool({ name: 'find_files', arguments: { path: path.dirname(file), pattern: '*.txt' } })).entries.length, 1); + t.diagnostic(JSON.stringify({ includeCount: 100, alternativesEach: 64, requestedTimeoutMs: 300, elapsedMs: result.elapsedMs, heartbeatMs, distinctPatternCode: limited.structuredContent.code })); +}); diff --git a/test/helper.cjs b/test/helper.cjs new file mode 100644 index 0000000..8708cde --- /dev/null +++ b/test/helper.cjs @@ -0,0 +1,61 @@ +/** 测试夹具:只在指定临时根目录读写,清理前校验绝对路径。 */ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { createServer } = require('../lib/server'); +const { createEncryption } = require('../lib/encryption'); +const { fingerprint } = require('../lib/text'); + +/** 初始化隔离测试目录和可注入复制器,不接触用户真实profile。 */ +async function fixture(t, settings = {}) { + const base = path.resolve(process.env.MCP_TEST_ROOT || path.join(os.tmpdir(), 'mcp-read-file-tests')); + await fs.mkdir(base, { recursive: true }); + const root = await fs.mkdtemp(path.join(base, 'case-')); + const stateDir = path.join(root, 'state'); + await fs.mkdir(stateDir); + const cleanupTasks = []; + t.after(async () => { + for (const task of cleanupTasks.reverse()) await task(); + const resolved = await fs.realpath(root); + const realBase = await fs.realpath(base); + if (!resolved.startsWith(realBase + path.sep)) throw new Error('清理路径越界'); + await fs.rm(resolved, { recursive: true, force: true }); + }); + const copied = new Set(); + const profile = { safeExtensions: ['.tmp'], byteReader: 'powershell', availableProcesses: [{ id: 'powershell' }], ...settings.profile }; + const opts = { stateDir, baseDir: root, allowedRoots: [root], probeDir: root, profile, + /** 模拟独立磁盘视图;生产控制流和实际样本文件保持真实执行。 */ + inspectDisk: async (proc, file, context) => { + if (settings.reader) return settings.reader({ proc, file, context, copied, root }); + return proc ? fingerprint(file, context) : null; + }, + /** 只复制独立测试文件;可注入损坏或失败验证事务回滚。 */ + externalCopy: async (proc, source, destination, context) => { + if (settings.copier) return settings.copier({ proc, source, destination, context, copied, root }); + await fs.copyFile(source, destination); + copied.add(destination); + }, + ...settings.options, + }; + const encryption = createEncryption(opts); + const app = createServer({ ...opts, encryption }); + /** 写入独立样本,父目录仅在本测试根下创建。 */ + async function sample(name, value) { + const file = path.resolve(root, name); + if (!file.startsWith(root + path.sep)) throw new Error('样本路径越界'); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, value); + return file; + } + /** 调用实际生产回调;协议和schema另外用真实stdio覆盖。 */ + async function call(name, args) { return app.handlers[name](args); } + return { ...app, root, stateDir, profile, opts, copied, sample, call, cleanupTasks }; +} + +/** 生成实际stdio使用的无外部进程测试缓存。 */ +function cachedProfile() { + return { version: 3, machineId: crypto.createHash('sha256').update(os.hostname() + '|' + os.userInfo().username).digest('hex').slice(0, 16), detectedAt: new Date().toISOString(), safeExtensions: [], protectedExtensions: [], unsafeExtensions: [], encryptedExtensions: [], availableProcesses: [], byteReader: null, bestCombo: null, scopes: {} }; +} + +module.exports = { fixture, cachedProfile }; diff --git a/test/protocol.test.js b/test/protocol.test.js new file mode 100644 index 0000000..649cbcc --- /dev/null +++ b/test/protocol.test.js @@ -0,0 +1,92 @@ +/** 真实SDK/stdio验收,不替换MCP传输和参数校验。 */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); +const { fixture, cachedProfile } = require('./helper.cjs'); + +/** 启动使用隔离profile和根目录的真实服务进程。 */ +async function connect(t, f, extraEnv = {}) { + await fs.writeFile(f.encryption.cachePath, JSON.stringify(cachedProfile())); + const transport = new StdioClientTransport({ command: process.execPath, args: [path.resolve(__dirname, '../index.js')], cwd: f.root, stderr: 'pipe', env: { ...process.env, MCP_PROFILE_DIR: f.stateDir, MCP_BASE_DIR: f.root, MCP_ALLOWED_ROOTS: JSON.stringify([f.root]), ...extraEnv } }); + const client = new Client({ name: 'regression', version: '1.0.0' }); + await client.connect(transport); + f.cleanupTasks.push(() => client.close()); + return client; +} + +/** 验证成功响应有稳定的结构化字段与可读文本。 */ +function data(response) { + assert.ok(!response.isError, JSON.stringify(response)); + assert.equal(response.structuredContent.ok, true); + return response.structuredContent.data; +} + +test('真实stdio:18个工具注册、输入输出schema和全部基础文件操作', async t => { + const f = await fixture(t); + const client = await connect(t, f); + const tools = await client.listTools(); + assert.equal(tools.tools.length, 18); + assert.ok(tools.tools.every(tool => tool.inputSchema && tool.outputSchema)); + assert.equal(client.getServerVersion().version, require('../package.json').version); + const file = path.join(f.root, 'hello.txt'); + data(await client.callTool({ name: 'write_file', arguments: { path: file, content: 'hello\r\nworld\r\n' } })); + assert.equal(data(await client.callTool({ name: 'read_file', arguments: { path: file } })).content, 'hello\nworld\n'); + data(await client.callTool({ name: 'edit_file', arguments: { path: file, oldString: 'hello\nworld', newString: 'first\nsecond' } })); + assert.equal(data(await client.callTool({ name: 'read_file_partial', arguments: { path: file, mode: 'lines', startLine: 2 } })).lines[0].text, 'second'); + assert.equal(data(await client.callTool({ name: 'read_files', arguments: { paths: [file] } })).entries.length, 1); + assert.equal(data(await client.callTool({ name: 'search_files', arguments: { path: file, pattern: 'second', include: '*.txt' } })).results.length, 1); + assert.equal(data(await client.callTool({ name: 'find_files', arguments: { path: f.root, pattern: '**/*.txt' } })).entries.length, 1); + assert.ok(data(await client.callTool({ name: 'list_directory', arguments: { path: f.root } })).entries.some(entry => entry.name === 'hello.txt')); + assert.ok(data(await client.callTool({ name: 'file_info', arguments: { path: file } })).hash); + const copied = path.join(f.root, 'copy.txt'); + const moved = path.join(f.root, 'moved.txt'); + data(await client.callTool({ name: 'copy_path', arguments: { source: file, destination: copied } })); + data(await client.callTool({ name: 'move_path', arguments: { source: copied, destination: moved } })); + assert.equal(data(await client.callTool({ name: 'remove_path', arguments: { path: moved, dryRun: true } })).changed, false); + data(await client.callTool({ name: 'remove_path', arguments: { path: moved } })); + data(await client.callTool({ name: 'create_directory', arguments: { path: path.join(f.root, 'empty') } })); + data(await client.callTool({ name: 'remove_path', arguments: { path: path.join(f.root, 'empty'), recursive: false } })); + assert.equal(data(await client.callTool({ name: 'check_status', arguments: {} })).decryptionVerified, false); + data(await client.callTool({ name: 'mark_extension', arguments: { extension: '.java', category: 'protected' } })); + assert.equal(data(await client.callTool({ name: 'inspect_write_strategy', arguments: { path: path.join(f.root, 'a.java') } })).mode, 'preserve'); + assert.equal(data(await client.callTool({ name: 'encryption_profile', arguments: {} })).overrides[0].category, 'protected'); + const bad = await client.callTool({ name: 'read_file_partial', arguments: { path: file, mode: 'chars', charCount: -1 } }); + assert.equal(bad.isError, true); + const tooLarge = await client.callTool({ name: 'search_files', arguments: { path: f.root, pattern: 'x', maxResults: 10000000 } }); + assert.equal(tooLarge.isError, true); + const invalidMark = await client.callTool({ name: 'mark_extension', arguments: { extension: '../evil', category: 'unsafe' } }); + assert.equal(invalidMark.isError, true); +}); + +test('真实stdio:正则计算中tools/list继续响应,超时后仍能读取文件', async t => { + const f = await fixture(t); + const file = await f.sample('redos.txt', 'a'.repeat(32) + '!'); + const client = await connect(t, f); + const pending = client.callTool({ name: 'search_files', arguments: { path: file, pattern: '^(a+)+$' } }); + const begin = performance.now(); + const tools = await client.listTools(); + const latency = performance.now() - begin; + assert.equal(tools.tools.length, 18); + assert.ok(latency < 800, 'tools/list耗时=' + latency); + const response = await pending; + assert.equal(response.isError, true); + assert.equal(response.structuredContent.code, 'REGEX_TIMEOUT'); + assert.equal(data(await client.callTool({ name: 'read_file', arguments: { path: file } })).content, 'a'.repeat(32) + '!'); + t.diagnostic(JSON.stringify({ toolsListLatencyMs: latency })); +}); + +test('真实stdio:只读模式允许读取、拒绝写入,根目录外路径拒绝', async t => { + const f = await fixture(t); + const file = await f.sample('keep.txt', 'keep'); + const client = await connect(t, f, { MCP_READ_ONLY: '1' }); + assert.equal(data(await client.callTool({ name: 'read_file', arguments: { path: file } })).content, 'keep'); + const write = await client.callTool({ name: 'write_file', arguments: { path: file, content: 'changed' } }); + assert.equal(write.structuredContent.code, 'READ_ONLY'); + const outside = await client.callTool({ name: 'read_file', arguments: { path: path.resolve(f.root, '..', 'outside') } }); + assert.equal(outside.structuredContent.code, 'PATH_OUTSIDE_ROOTS'); + assert.equal(await fs.readFile(file, 'utf8'), 'keep'); +}); diff --git a/test/regression.test.js b/test/regression.test.js new file mode 100644 index 0000000..9934bc6 --- /dev/null +++ b/test/regression.test.js @@ -0,0 +1,441 @@ +/** 原审查B01—B26的正确行为断言,以及新增功能与异常提交验收。 */ +const { test, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { performance } = require('node:perf_hooks'); +const { fixture, cachedProfile } = require('./helper.cjs'); +const { createEncryption } = require('../lib/encryption'); +const { createServer } = require('../lib/server'); +const { readText, fingerprint, payloadFingerprint, fault } = require('../lib/text'); +const { globToRegex, globMatcher, applyLiteral } = require('../lib/patterns'); +const { runRegex, stop } = require('../lib/regex'); +after(() => stop()); + +/** 从工具成功结果中提取数据;错误文字一并显示便于定位。 */ +function success(response) { + assert.equal(!!response.isError, false, response.content[0].text); + assert.equal(response.structuredContent.ok, true); + return response.structuredContent.data; +} +/** 断言业务错误可被协议识别,不靠成功文本后的警告。 */ +function failure(response, code) { + assert.equal(response.isError, true, response.content[0].text); + assert.equal(response.structuredContent.ok, false); + if (code) assert.equal(response.structuredContent.code, code, response.content[0].text); + return response.structuredContent.data; +} + +test('B01 UTF8跨4KB/64KB及BOM边界,编辑其他位置不改变原字节', async t => { + const f = await fixture(t); + for (const length of [4095, 4096, 65535, 65534]) { + for (const character of ['中', '😀']) { + const content = '\uFEFF' + 'a'.repeat(length) + character + '\r\nold\r\n'; + const file = await f.sample('boundary.txt', content); + success(await f.call('edit_file', { path: file, oldString: 'old', newString: 'new' })); + assert.equal(await fs.readFile(file, 'utf8'), content.replace('old', 'new')); + } + } +}); + +test('B02 搜索8KB边界中文不漏报', async t => { + const f = await fixture(t); + const file = await f.sample('search.txt', 'a'.repeat(8191) + '中needle'); + const data = success(await f.call('search_files', { path: file, pattern: '中needle', onlyMatching: true })); + assert.equal(data.results[0].text, '中needle'); +}); + +test('B03 短GBK、低比例非法UTF8被拒绝,合法替换字符不误报', async t => { + const f = await fixture(t); + for (const prefix of ['old', 'a'.repeat(20000) + 'old']) { + const bytes = Buffer.concat([Buffer.from(prefix), Buffer.from([0xd6, 0xd0, 0xce, 0xc4])]); + const file = await f.sample('invalid.txt', bytes); + failure(await f.call('edit_file', { path: file, oldString: 'old', newString: 'new' }), 'INVALID_UTF8'); + assert.deepEqual(await fs.readFile(file), bytes); + } + const file = await f.sample('valid.txt', '\uFFFD'.repeat(200) + 'old'); + success(await f.call('edit_file', { path: file, oldString: 'old', newString: 'new' })); + assert.equal(await fs.readFile(file, 'utf8'), '\uFFFD'.repeat(200) + 'new'); +}); + +test('B04 append进入自动纠正时保留原文、BOM和CRLF', async t => { + const f = await fixture(t, { + /** 仅模拟直接暂存文件被加密,外部复制完成后返回明文视图。 */ + reader: async ({ file, copied, context }) => { + const actual = await fingerprint(file, context); + if (path.basename(file).startsWith('.mcp-stage-') && !copied.has(file)) return { hash: 'f'.repeat(64), size: actual.size + 128, prefix: '25545344' }; + return actual; + }, + }); + const file = await f.sample('append.foo', '\uFEFFORIGINAL\r\n'); + success(await f.call('write_file', { path: file, content: 'APPEND\n', mode: 'append' })); + assert.equal(await fs.readFile(file, 'utf8'), '\uFEFFORIGINAL\r\nAPPEND\r\n'); + assert.ok(f.copied.size > 0); +}); + +test('B05 safeWrite不得覆盖或删除同名现有备份', async t => { + const f = await fixture(t); + const file = await f.sample('note.scss', 'old'); + const backup = await f.sample('note.scss.tmp', 'USER BACKUP'); + success(await f.call('mark_extension', { extension: '.scss', category: 'unsafe' })); + success(await f.call('write_file', { path: file, content: 'new' })); + assert.equal(await fs.readFile(backup, 'utf8'), 'USER BACKUP'); +}); + +test('B06 源名等于旧临时名的复制不删源', async t => { + const f = await fixture(t); + const source = await f.sample('code.scss.tmp', 'SOURCE'); + success(await f.call('mark_extension', { extension: '.scss', category: 'unsafe' })); + const destination = path.join(f.root, 'code.scss'); + success(await f.call('copy_path', { source, destination })); + assert.equal(await fs.readFile(source, 'utf8'), 'SOURCE'); + assert.equal(await fs.readFile(destination, 'utf8'), 'SOURCE'); +}); + +test('B07 同路径及移动到当前父目录不删除文件', async t => { + const f = await fixture(t); + const file = await f.sample('same.scss', 'SOURCE'); + success(await f.call('mark_extension', { extension: '.scss', category: 'unsafe' })); + for (const destination of [file, f.root]) { + assert.equal(success(await f.call('move_path', { source: file, destination })).changed, false); + assert.equal(await fs.readFile(file, 'utf8'), 'SOURCE'); + } +}); + +test('B08 unsafe和protected均可clear,独立实例立即可见', async t => { + const f = await fixture(t); + const other = createEncryption(f.opts); + for (const category of ['unsafe', 'protected']) { + success(await f.call('mark_extension', { extension: 'JAVA', category })); + assert.equal(await other.getOverride('.java'), category); + success(await f.call('mark_extension', { extension: '.java', category: 'clear' })); + assert.equal(await other.getOverride('.java'), null); + } +}); + +test('B09 刷新、TTL到期和重启保留手工策略', async t => { + const f = await fixture(t); + await f.encryption.mark('.java', 'protected'); + await f.encryption.mark('.scss', 'unsafe'); + await f.encryption.refresh(); + const p = { ...(await f.encryption.getProfile()), detectedAt: '2000-01-01T00:00:00Z' }; + await fs.writeFile(f.encryption.cachePath, JSON.stringify(p)); + const restarted = createEncryption(f.opts); + await restarted.getProfile(); + assert.equal(await restarted.getOverride('.java'), 'protected'); + assert.equal(await restarted.getOverride('.scss'), 'unsafe'); +}); + +test('B09 过期v2的protected标注只迁移一次,clear不被旧缓存复活', async t => { + const f = await fixture(t); + const p = { ...cachedProfile(), version: 2, detectedAt: '2000-01-01', userProtectedExtensions: ['.java'] }; + await fs.writeFile(f.encryption.cachePath, JSON.stringify(p)); + assert.equal(await f.encryption.getOverride('.java'), 'protected'); + await f.encryption.mark('.java', 'clear'); + assert.equal(await createEncryption(f.opts).getOverride('.java'), null); +}); + +test('B10/B11/B12 include递归扩展名、花括号和单文件过滤', async t => { + const f = await fixture(t); + await f.sample('src/a.java', 'NEEDLE'); + const ts = await f.sample('a.ts', 'NEEDLE'); + await f.sample('b.tsx', 'NEEDLE'); + assert.equal(success(await f.call('search_files', { path: f.root, pattern: 'NEEDLE', include: '*.java' })).results.length, 1); + assert.equal(success(await f.call('search_files', { path: f.root, pattern: 'NEEDLE', include: '**/*.{ts,tsx}' })).results.length, 2); + assert.equal(success(await f.call('search_files', { path: ts, pattern: 'NEEDLE', include: '*.ts' })).results.length, 1); + assert.equal(success(await f.call('search_files', { path: f.root, pattern: 'NEEDLE', include: ['**/*.ts', '**/*.tsx'] })).results.length, 2); +}); + +test('B13/B14 glob字面括号与完整目录段语义', () => { + assert.ok(globToRegex('app/(auth)/page.tsx').test('app/(auth)/page.tsx')); + assert.ok(!globToRegex('app/(auth)/page.tsx').test('app/auth/page.tsx')); + assert.ok(globToRegex('**/foo.js').test('foo.js')); + assert.ok(globToRegex('**/foo.js').test('x/y/foo.js')); + assert.ok(!globToRegex('**/foo.js').test('notfoo.js')); + assert.ok(globToRegex('src/**/*.{ts,tsx}').test('src/a.tsx')); + assert.ok(globToRegex('{src,{lib,test}}/*.js').test('test/a.js')); + assert.ok(globMatcher(['{**/*.js,**/*.ts}'])('a.js')); + assert.ok(globMatcher(['{**/*.js,**/*.ts}'])('src/a.ts')); + assert.ok(!globMatcher(['**/foo.js'])('notfoo.js')); + assert.ok(!globMatcher(['*a'.repeat(30) + 'b'])('a'.repeat(100))); +}); + +test('B15 空目录非递归删除成功,非空目录失败不丢内容', async t => { + const f = await fixture(t); + const empty = path.join(f.root, 'empty'); + await fs.mkdir(empty); + success(await f.call('remove_path', { path: empty, recursive: false })); + await assert.rejects(fs.access(empty)); + const file = await f.sample('nonempty/keep.txt', 'keep'); + failure(await f.call('remove_path', { path: path.dirname(file), recursive: false })); + assert.equal(await fs.readFile(file, 'utf8'), 'keep'); +}); + +test('B16 密文/非法字节不得报告解密正常;可信hash可明确验证', async t => { + const f = await fixture(t); + const cipher = await f.sample('cipher.java', '%TSD-Header-###%ABC'); + failure(await f.call('check_status', { path: cipher }), 'CIPHERTEXT_DETECTED'); + const plain = await f.sample('plain.txt', 'plaintext'); + assert.equal(success(await f.call('check_status', { path: plain })).decryptionVerified, false); + assert.equal(success(await f.call('check_status', { path: plain, expectedHash: (await fingerprint(plain)).hash })).decryptionVerified, true); +}); + +test('B17/B18 损坏复制不能自比较通过,编辑safe失败必须报错并保留原文', async t => { + const f = await fixture(t, { + /** 模拟外部复制返回成功但实际写坏内容。 */ + copier: async ({ destination }) => fs.writeFile(destination, 'CORRUPTED'), + }); + const file = await f.sample('x.scss', 'old'); + await f.encryption.mark('.scss', 'unsafe'); + const response = await f.call('edit_file', { path: file, oldString: 'old', newString: 'new' }); + failure(response, 'SAFE_WRITE_FAILED'); + assert.equal(await fs.readFile(file, 'utf8'), 'old'); + assert.equal(response.structuredContent.changed, false); +}); + +test('B19 小文件与分块路径均不切断代理对,分页拼接无损', async t => { + const f = await fixture(t); + for (const content of ['a😀z', '😀', 'a'.repeat(65535) + '😀z']) { + const file = await f.sample('unicode.txt', content); + let offset = 0; + let restored = ''; + do { + const data = await readText(file, { limit: content.length > 100 ? 65536 : 2, offset }); + assert.ok(!/[\uD800-\uDBFF]$/.test(data.content)); + restored += data.content; + offset = data.nextOffset; + } while (offset !== null); + assert.equal(restored, content); + } +}); + +test('B20 大文件截断元数据有效,没有undefined字节提示', async t => { + const f = await fixture(t); + const file = await f.sample('large.txt', 'a'.repeat(500000)); + const response = await f.call('read_file', { path: file }); + const data = success(response); + assert.equal(data.nextOffset, 400000); + assert.equal(data.size, 400000); + assert.ok(!response.content[0].text.includes('undefined')); +}); + +test('B21 Unicode忽略大小写替换使用原始索引,批量也生效', async t => { + assert.equal(applyLiteral('İABC', { oldString: 'ABC', newString: 'X' }, true).updated, 'İX'); + const f = await fixture(t); + const file = await f.sample('case.txt', 'İABC\r\nDeF'); + const data = success(await f.call('edit_file', { path: file, edits: [{ oldString: 'abc\ndef', newString: 'X' }], ignoreCase: true })); + assert.equal(data.replaced, 1); + assert.equal(await fs.readFile(file, 'utf8'), 'İX'); +}); + +test('B22 UTF16追加被拒绝且原始字节不变', async t => { + const f = await fixture(t); + const bytes = Buffer.concat([Buffer.from([255, 254]), Buffer.from('old', 'utf16le')]); + const file = await f.sample('utf16.txt', bytes); + failure(await f.call('write_file', { path: file, content: 'new', mode: 'append' }), 'UNSUPPORTED_ENCODING'); + assert.deepEqual(await fs.readFile(file), bytes); +}); + +test('B23 非TSD磁盘不一致不作为明文成功,所有候选失败保留原文', async t => { + const f = await fixture(t, { + /** 非TSD磁盘内容与预期不一致。 */ + reader: async ({ file, context }) => ({ ...(await fingerprint(file, context)), hash: 'f'.repeat(64), prefix: 'aabbccdd' }), + }); + const file = await f.sample('x.foo', 'old'); + await assert.rejects(f.encryption.verify(file, payloadFingerprint('old'), 'auto'), { code: 'DISK_MISMATCH' }); + failure(await f.call('write_file', { path: file, content: 'new', writePolicy: 'plaintext' })); + assert.equal(await fs.readFile(file, 'utf8'), 'old'); +}); + +test('B24 读取器失效不得认证明文,强制明文失败保留目标与源', async t => { + const f = await fixture(t, { reader: async () => null }); + const file = await f.sample('x.scss', 'old'); + await assert.rejects(f.encryption.verify(file, payloadFingerprint('old'), 'plaintext'), { code: 'DISK_UNVERIFIED' }); + failure(await f.call('write_file', { path: file, content: 'new', writePolicy: 'plaintext' })); + const source = await f.sample('source.txt', 'SOURCE'); + failure(await f.call('move_path', { source, destination: file, writePolicy: 'plaintext' })); + assert.equal(await fs.readFile(file, 'utf8'), 'old'); + assert.equal(await fs.readFile(source, 'utf8'), 'SOURCE'); +}); + +test('B25 无效日期、未来日期、不完整字段和非法进程缓存均拒绝', async t => { + const f = await fixture(t); + for (const change of [{ detectedAt: 'not-a-date' }, { detectedAt: '2999-01-01' }, { unsafeExtensions: null }, { byteReader: 'evil' }, { availableProcesses: [{ id: 'evil' }] }]) { + await fs.writeFile(f.encryption.cachePath, JSON.stringify({ ...cachedProfile(), ...change })); + assert.equal(await f.encryption.loadCache(), null); + } +}); + +test('B26 灾难性正则超时后可继续搜索与编辑,主线程计时器正常', async t => { + const f = await fixture(t); + const file = await f.sample('redos.txt', 'a'.repeat(32) + '!'); + let ticked = false; + const timer = setTimeout(() => { ticked = true; }, 20); + failure(await f.call('search_files', { path: file, pattern: '^(a+)+$' }), 'REGEX_TIMEOUT'); + clearTimeout(timer); + assert.ok(ticked); + assert.equal(success(await f.call('search_files', { path: file, pattern: '!', mode: 'literal' })).results.length, 1); + failure(await f.call('edit_file', { path: file, oldString: '^(a+)+$', newString: 'X', useRegex: true }), 'REGEX_TIMEOUT'); + assert.equal(await fs.readFile(file, 'utf8'), 'a'.repeat(32) + '!'); +}); + +test('优化 编辑dryRun、expectedHash、expectedMatches与批量计数', async t => { + const f = await fixture(t); + const file = await f.sample('edit.txt', 'foo foo\r\nbar'); + const original = await fs.readFile(file); + const dry = success(await f.call('edit_file', { path: file, oldString: 'foo', newString: 'x', dryRun: true })); + assert.equal(dry.changed, false); + assert.equal(dry.replaced, 1); + assert.equal(dry.matched, 2); + assert.deepEqual(await fs.readFile(file), original); + failure(await f.call('edit_file', { path: file, oldString: 'foo', newString: 'x', expectedMatches: 1 }), 'MATCH_COUNT_MISMATCH'); + failure(await f.call('edit_file', { path: file, oldString: 'foo', newString: 'x', expectedHash: '0'.repeat(64) }), 'CONFLICT'); + const batch = success(await f.call('edit_file', { path: file, edits: [{ oldString: 'foo', newString: 'x' }, { oldString: 'bar', newString: 'b' }] })); + assert.equal(batch.replaced, 2); + assert.equal(batch.matched, 3); +}); + +test('优化 批量失败无修改,混合参数拒绝,捕获组与字面替换语义保留', async t => { + const f = await fixture(t); + const file = await f.sample('batch.txt', 'foo\r\nbar'); + failure(await f.call('edit_file', { path: file, edits: [{ oldString: 'foo', newString: 'x' }, { oldString: 'missing', newString: 'y' }] }), 'NO_MATCH'); + assert.equal(await fs.readFile(file, 'utf8'), 'foo\r\nbar'); + failure(await f.call('edit_file', { path: file, oldString: 'foo', newString: 'x', edits: [{ oldString: 'foo', newString: 'x' }] }), 'AMBIGUOUS_EDIT'); + success(await f.call('edit_file', { path: file, oldString: '(foo)', newString: '$1X', useRegex: true })); + success(await f.call('edit_file', { path: file, oldString: 'bar', newString: '$&' })); + assert.equal(await fs.readFile(file, 'utf8'), 'fooX\r\n$&'); +}); + +test('优化 提交后磁盘验证失败回滚原始字节', async t => { + const f = await fixture(t); + const file = await f.sample('rollback.txt', 'ORIGINAL'); + const originalVerify = f.encryption.verify; + /** 在最终路径阶段注入校验失败,模拟rename后目录策略变化。 */ + f.encryption.verify = async (target, expected, mode, context) => { + if (target === file && expected.hash !== payloadFingerprint('ORIGINAL').hash) throw fault('DISK_MISMATCH', '模拟提交后校验失败'); + return originalVerify(target, expected, mode, context); + }; + failure(await f.call('write_file', { path: file, content: 'NEW' }), 'DISK_MISMATCH'); + assert.equal(await fs.readFile(file, 'utf8'), 'ORIGINAL'); + assert.ok(!(await fs.readdir(f.root)).some(name => name.startsWith('.mcp-'))); +}); + +test('优化 暂存阶段ENOSPC不截断原文件', async t => { + const f = await fixture(t); + const file = await f.sample('diskfull.txt', 'ORIGINAL'); + /** 注入写入一半后失败,验证最终文件尚未被覆盖。 */ + f.encryption.prepare = async stage => { await fs.writeFile(stage, 'PART'); throw fault('ENOSPC', '模拟磁盘满'); }; + failure(await f.call('write_file', { path: file, content: 'NEW' }), 'ENOSPC'); + assert.equal(await fs.readFile(file, 'utf8'), 'ORIGINAL'); +}); + +test('优化 文件和目录均应用unsafe策略,目录失败保留源', async t => { + const f = await fixture(t); + const source = await f.sample('src/a.scss', 'A'); + await f.sample('src/nested/b.txt', 'B'); + await f.encryption.mark('.scss', 'unsafe'); + const destination = path.join(f.root, 'dst'); + success(await f.call('copy_path', { source: path.dirname(source), destination })); + assert.equal(await fs.readFile(path.join(destination, 'a.scss'), 'utf8'), 'A'); + assert.ok(f.copied.size > 0); + success(await f.call('move_path', { source: path.dirname(source), destination: path.join(f.root, 'moved') })); + await assert.rejects(fs.access(path.dirname(source))); +}); + +test('优化 目录移动不能删除复制期间新出现的源文件', async t => { + const f = await fixture(t); + const source = await f.sample('src/a.txt', 'A'); + const destination = path.join(f.root, 'dst'); + const originalVerify = f.encryption.verify; + /** 最终目标验证时新增未被复制的源文件。 */ + f.encryption.verify = async (target, expected, mode, context) => { + if (target === path.join(destination, 'a.txt')) await fs.writeFile(path.join(path.dirname(source), 'new.txt'), 'NEW'); + return originalVerify(target, expected, mode, context); + }; + failure(await f.call('move_path', { source: path.dirname(source), destination })); + assert.equal(await fs.readFile(path.join(path.dirname(source), 'new.txt'), 'utf8'), 'NEW'); + assert.equal(await fs.readFile(path.join(destination, 'a.txt'), 'utf8'), 'A'); +}); + +test('优化 hash冲突、禁止覆盖、根目录保护、只读边界', async t => { + const f = await fixture(t); + const file = await f.sample('keep.txt', 'old'); + failure(await f.call('write_file', { path: file, content: 'new', overwrite: false }), 'ALREADY_EXISTS'); + failure(await f.call('write_file', { path: file, content: 'new', expectedHash: '0'.repeat(64) }), 'CONFLICT'); + failure(await f.call('remove_path', { path: f.root }), 'PROTECTED_ROOT'); + failure(await f.call('read_file', { path: path.resolve(f.root, '..', 'outside.txt') }), 'PATH_OUTSIDE_ROOTS'); + const readOnly = createServer({ ...f.opts, encryption: f.encryption, readOnly: true }); + failure(await readOnly.handlers.write_file({ path: file, content: 'new' }), 'READ_ONLY'); + assert.equal(await fs.readFile(file, 'utf8'), 'old'); +}); + +test('优化 多个实例并发追加不丢失更新,策略不同扩展名互不覆盖', async t => { + const f = await fixture(t); + const second = createServer({ ...f.opts, encryption: createEncryption(f.opts) }); + const file = await f.sample('concurrent.txt', 'base\n'); + const responses = await Promise.all([f.call('write_file', { path: file, content: 'A\n', mode: 'append' }), second.handlers.write_file({ path: file, content: 'B\n', mode: 'append' })]); + responses.forEach(success); + const content = await fs.readFile(file, 'utf8'); + assert.ok(content.includes('A\n') && content.includes('B\n')); + await Promise.all([f.encryption.mark('.java', 'protected'), second.encryption.mark('.scss', 'unsafe')]); + assert.equal(await f.encryption.getOverride('.java'), 'protected'); + assert.equal(await f.encryption.getOverride('.scss'), 'unsafe'); +}); + +test('优化 隐藏项开关、字面量搜索、上下文和搜索总输出预算', async t => { + const f = await fixture(t); + await f.sample('.hidden/a.txt', 'before\na.b\nafter'); + assert.equal(success(await f.call('search_files', { path: f.root, pattern: 'a.b', include: '*.txt' })).results.length, 0); + const data = success(await f.call('search_files', { path: f.root, pattern: 'a.b', mode: 'literal', showHidden: true, include: '*.txt', contextLines: 1 })); + assert.deepEqual(data.results[0].context, { before: ['before'], after: ['after'] }); + const huge = await f.sample('huge.txt', 'X'.repeat(1000000)); + const bounded = success(await f.call('search_files', { path: huge, pattern: 'X' })); + assert.equal(bounded.truncated, true); + assert.ok(JSON.stringify(bounded).length < 410000); +}); + +test('优化 CR-only行分页、批量总预算、原始字节大小含BOM', async t => { + const f = await fixture(t); + const file = await f.sample('lines.txt', '\uFEFFone\rtwo\rthree\r'); + const page = success(await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 2, endLine: 2 })); + assert.deepEqual(page.lines, [{ line: 2, text: 'two' }]); + assert.equal(page.nextLine, 3); + assert.equal(success(await f.call('file_info', { path: file })).sizeReadable, (await fs.readFile(file)).length); + const a = await f.sample('a.txt', 'a'.repeat(300000)); + const b = await f.sample('b.txt', 'b'.repeat(300000)); + const batch = success(await f.call('read_files', { paths: [a, b] })); + assert.ok(batch.entries.reduce((total, item) => total + (item.content?.length || 0), 0) <= 400001); +}); + +test('优化 100MB字符/行分页保持有界内存与及时返回', async t => { + const f = await fixture(t); + const file = await f.sample('100mb.txt', 'first\nsecond\n'); + // 稀疏文件用于验证不会为了前缀或前两行读取剩余100MB。 + await fs.truncate(file, 100 * 1024 * 1024); + // 读取器严格拒绝NUL;改用真实重复文本分块写入,保证测试覆盖有效文本。 + const handle = await fs.open(file, 'w'); + try { + const block = Buffer.from(('a'.repeat(99) + '\n').repeat(10000)); + for (let i = 0; i < 100; i++) await handle.write(block); + } finally { await handle.close(); } + const before = process.memoryUsage().rss; + const start = performance.now(); + const prefix = success(await f.call('read_file', { path: file })); + const lines = success(await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 1, endLine: 2 })); + assert.equal(prefix.content.length, 400000); + assert.equal(lines.lines.length, 2); + const rssDelta = process.memoryUsage().rss - before; + const elapsed = performance.now() - start; + t.diagnostic(JSON.stringify({ bytes: (await fs.stat(file)).size, elapsedMs: elapsed, rssDeltaBytes: rssDelta })); + assert.ok(elapsed < 5000); + assert.ok(rssDelta < 100 * 1024 * 1024); +}); + +test('优化 取消正则任务后服务可恢复', async () => { + const controller = new AbortController(); + const promise = runRegex({ operation: 'edit', pattern: '^(a+)+$', text: 'a'.repeat(32) + '!', replacement: 'x' }, { signal: controller.signal }); + controller.abort(); + await assert.rejects(promise, { code: 'CANCELLED' }); + const next = await runRegex({ operation: 'edit', pattern: 'a', text: 'a', replacement: 'b' }); + assert.equal(next.updated, 'b'); +}); diff --git a/test/resilience.test.js b/test/resilience.test.js new file mode 100644 index 0000000..cf231be --- /dev/null +++ b/test/resilience.test.js @@ -0,0 +1,263 @@ +/** N1—N4的正确行为回归:目录互斥、清理诊断、部分删除与字面量预算。 */ +const { test, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { performance } = require('node:perf_hooks'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); +const { fixture, cachedProfile } = require('./helper.cjs'); +const { createServer } = require('../lib/server'); +const { fault } = require('../lib/text'); +const { runRegex, runLiteral, stop } = require('../lib/regex'); +after(() => stop()); + +/** 创建确定性调度屏障,不改变文件操作本身。 */ +function gate() { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; } +/** 给测试自己的屏障设置上限,失败也不会无限挂起。 */ +async function bounded(promise, ms = 5000) { + let timer; + try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('测试屏障超时')), ms); })]); } + finally { clearTimeout(timer); } +} +/** 取得协议成功结果并保留失败内容用于诊断。 */ +function success(response) { assert.equal(response.structuredContent.ok, true, JSON.stringify(response)); return response.structuredContent.data; } +/** 断言错误码后返回状态字段。 */ +function failure(response, code) { assert.equal(response.structuredContent.ok, false); assert.equal(response.structuredContent.code, code); return response.structuredContent.data; } + +test('N1 目录移动与另一个实例写源子文件互斥,已确认的新内容不会丢失', async t => { + const f = await fixture(t); + const other = createServer({ ...f.opts, encryption: f.encryption }); + const source = await f.sample('src/a.txt', 'OLD'); + const destination = path.join(f.root, 'dst'); + const reached = gate(), release = gate(); + const realUnlink = fs.unlink; + // 暂停在最后一次源hash校验后、真实删除前。 + fs.unlink = async file => { if (file === source) { reached.resolve(); await release.promise; } return realUnlink(file); }; + const moving = f.call('move_path', { source: path.dirname(source), destination, writePolicy: 'preserve' }); + let writing; + let settled = false; + try { + await bounded(reached.promise); + writing = other.handlers.write_file({ path: source, content: 'ACKNOWLEDGED-NEW', writePolicy: 'preserve' }).then(value => { settled = true; return value; }); + await new Promise(resolve => setTimeout(resolve, 100)); + assert.equal(settled, false, '子文件写入应等待目录移动完成'); + // 无关路径不因目录操作而被串行阻塞。 + success(await bounded(other.handlers.write_file({ path: path.join(f.root, 'independent.txt'), content: 'PARALLEL', writePolicy: 'preserve' }), 1500)); + release.resolve(); + success(await moving); + success(await writing); + assert.equal(await fs.readFile(source, 'utf8'), 'ACKNOWLEDGED-NEW'); + assert.equal(await fs.readFile(path.join(destination, 'a.txt'), 'utf8'), 'OLD'); + } finally { release.resolve(); await moving; if (writing) await writing; fs.unlink = realUnlink; } +}); + +test('N1 目录占用期间,删除目录和创建子目录都必须等待', async t => { + const f = await fixture(t); + const file = await f.sample('tree/a.txt', 'KEEP'); + const reached = gate(), release = gate(); + const holding = f.files.locked([path.dirname(file)], async () => { reached.resolve(); await release.promise; return { changed: false }; }); + try { + await bounded(reached.promise); + for (const [name, args] of [['remove_path', { path: path.dirname(file) }], ['create_directory', { path: path.join(path.dirname(file), 'child') }]]) { + failure(await f.handlers[name](args, { signal: AbortSignal.timeout(100) }), 'CANCELLED'); + } + assert.equal(await fs.readFile(file, 'utf8'), 'KEEP'); + } finally { release.resolve(); await holding; } +}); + +test('N1 跨进程的子文件锁阻止父目录操作,反向路径组不会死锁', async t => { + const f = await fixture(t); + const file = await f.sample('tree/a.txt', 'KEEP'); + const code = "const locked=require(process.argv[1]).createLocker(process.argv[2]);locked([process.argv[3]],async()=>{process.stdout.write('READY\\n');for await(const chunk of process.stdin){break;}return {changed:false};}).catch(e=>{console.error(e);process.exitCode=1;});"; + const child = spawn(process.execPath, ['-e', code, path.resolve(__dirname, '../lib/locks.js'), f.stateDir, file], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const ready = gate(), closed = gate(); + let output = ''; + child.stdout.on('data', chunk => { output += chunk; if (output.includes('READY')) ready.resolve(); }); + child.on('error', error => ready.resolve(error)); + child.on('close', () => closed.resolve()); + try { + await bounded(ready.promise); + failure(await f.handlers.remove_path({ path: path.dirname(file) }, { signal: AbortSignal.timeout(120) }), 'CANCELLED'); + } finally { child.stdin.end('\n'); try { await bounded(closed.promise); } catch { child.kill(); await bounded(closed.promise); } } + const a = path.join(f.root, 'a'), b = path.join(f.root, 'b'); + const values = await bounded(Promise.all([f.files.locked([a, b], async () => ({ changed: false, value: 1 })), f.files.locked([b, a], async () => ({ changed: false, value: 2 }))])); + assert.deepEqual(values.map(x => x.value), [1, 2]); + assert.equal(await fs.readFile(file, 'utf8'), 'KEEP'); +}); + +test('N2 成功写入后锁清理失败,仍报告成功改变并附清理告警', async t => { + const f = await fixture(t); + const file = await f.sample('target.txt', 'OLD'); + const realRm = fs.rm; + // 只让本案例租约文件清理失败,不影响登记临界区。 + fs.rm = async (target, options) => { if (String(target).startsWith(f.root) && String(target).endsWith('.lock')) throw fault('EBUSY', '模拟锁清理失败'); return realRm(target, options); }; + let response; + try { response = await f.call('write_file', { path: file, content: 'NEW', writePolicy: 'preserve' }); } + finally { fs.rm = realRm; } + const data = success(response); + assert.equal(data.changed, true); + assert.equal(response.structuredContent.changed, true); + assert.equal(data.cleanupErrors[0].code, 'EBUSY'); + assert.ok(response.structuredContent.warnings.length); + assert.equal(await fs.readFile(file, 'utf8'), 'NEW'); +}); + +test('N2 安装、回滚和两项清理连续失败仍保留原错误与恢复备份', async t => { + const f = await fixture(t); + const file = await f.sample('target.txt', 'ORIGINAL'); + const realRename = fs.rename, realRm = fs.rm; + // 故障均精确限制到当前样本及其暂存/锁文件。 + fs.rename = async (from, to) => { + if (to === file && path.basename(from).startsWith('.mcp-stage-')) throw fault('EACCES', '模拟安装失败'); + if (to === file && path.basename(from).startsWith('.mcp-backup-')) throw fault('EBUSY', '模拟回滚失败'); + return realRename(from, to); + }; + fs.rm = async (target, options) => { + if (String(target).startsWith(f.root) && (path.basename(target).startsWith('.mcp-stage-') || target.endsWith('.lock'))) throw fault('EPERM', '模拟清理失败'); + return realRm(target, options); + }; + let response; + try { response = await f.call('write_file', { path: file, content: 'NEW', writePolicy: 'preserve' }); } + finally { fs.rename = realRename; fs.rm = realRm; } + const data = failure(response, 'EACCES'); + assert.equal(data.changed, true); + assert.equal(data.cleanupErrors.length, 2); + assert.match(data.rollbackError, /回滚失败/); + assert.equal(await fs.readFile(data.recoveryPath, 'utf8'), 'ORIGINAL'); + await assert.rejects(fs.access(file), { code: 'ENOENT' }); +}); + +test('N2 暂存写满磁盘与清理同时失败,不覆盖原始ENOSPC', async t => { + const f = await fixture(t); + const file = await f.sample('target.txt', 'ORIGINAL'); + f.encryption.prepare = async stage => { await fs.writeFile(stage, 'PART'); throw fault('ENOSPC', '模拟磁盘满'); }; + const realRm = fs.rm; + fs.rm = async (target, options) => { if (path.dirname(target) === f.root && path.basename(target).startsWith('.mcp-stage-')) throw fault('EBUSY', '模拟暂存清理失败'); return realRm(target, options); }; + let response; + try { response = await f.call('write_file', { path: file, content: 'NEW', writePolicy: 'preserve' }); } + finally { fs.rm = realRm; } + const data = failure(response, 'ENOSPC'); + assert.equal(data.changed, false); + assert.equal(data.cleanupErrors.length, 1); + assert.equal(await fs.readFile(file, 'utf8'), 'ORIGINAL'); +}); + +test('N2 目录复制成功时保留子文件的清理诊断', async t => { + const f = await fixture(t); + const source = await f.sample('src/a.txt', 'A'); + const destination = path.join(f.root, 'dst'); + const realRm = fs.rm; + // 提交已经完成,仅模拟清理阶段权限错误,检查诊断跨目录包装保留。 + fs.rm = async (target, options) => { if (path.dirname(target) === destination && path.basename(target).startsWith('.mcp-stage-')) throw fault('EACCES', '模拟清理阶段访问失败'); return realRm(target, options); }; + let response; + try { response = await f.call('copy_path', { source: path.dirname(source), destination, writePolicy: 'preserve' }); } + finally { fs.rm = realRm; } + const data = success(response); + assert.equal(data.changed, true); + assert.equal(data.cleanupErrors[0].code, 'EACCES'); + assert.ok(response.structuredContent.warnings.length); + assert.equal(await fs.readFile(path.join(destination, 'a.txt'), 'utf8'), 'A'); +}); + +test('N3 递归删除中途失败返回已删除项、准确计数与失败路径', async t => { + const f = await fixture(t); + const a = await f.sample('tree/a.txt', 'A'); + const b = await f.sample('tree/b.txt', 'B'); + const realUnlink = fs.unlink; + fs.unlink = async file => { if (file === b) throw fault('EBUSY', '模拟占用'); return realUnlink(file); }; + let response; + try { response = await f.call('remove_path', { path: path.dirname(a) }); } + finally { fs.unlink = realUnlink; } + const data = failure(response, 'EBUSY'); + assert.equal(data.changed, true); + assert.deepEqual(data.partial, [a]); + assert.equal(data.removedCount, 1); + assert.equal(data.failedPath, b); + assert.equal(data.partialTruncated, false); + await assert.rejects(fs.access(a), { code: 'ENOENT' }); + assert.equal(await fs.readFile(b, 'utf8'), 'B'); +}); + +test('N3 删除部分完成后取消仍保留准确状态', async t => { + const f = await fixture(t); + const a = await f.sample('tree/a.txt', 'A'); + const b = await f.sample('tree/b.txt', 'B'); + const controller = new AbortController(); + const realUnlink = fs.unlink; + fs.unlink = async file => { await realUnlink(file); if (file === a) controller.abort(); }; + let response; + try { response = await f.handlers.remove_path({ path: path.dirname(a) }, { signal: controller.signal }); } + finally { fs.unlink = realUnlink; } + const data = failure(response, 'CANCELLED'); + assert.equal(data.changed, true); + assert.equal(data.removedCount, 1); + assert.deepEqual(data.partial, [a]); + assert.equal(await fs.readFile(b, 'utf8'), 'B'); +}); + +test('N3 Windows真实独占句柄造成部分删除,返回状态与磁盘一致', { skip: process.platform !== 'win32' }, async t => { + const f = await fixture(t); + const a = await f.sample('tree/a.txt', 'FREE'); + const b = await f.sample('tree/b.txt', 'LOCKED'); + const quoted = "'" + b.replace(/'/g, "''") + "'"; + const code = "$ErrorActionPreference='Stop';$f=[IO.File]::Open(" + quoted + ",[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None);try{[Console]::Out.WriteLine('READY');[Console]::Out.Flush();[Console]::ReadLine()|Out-Null}finally{$f.Dispose()}"; + const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', code], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const ready = gate(), closed = gate(); + let output = ''; + child.stdout.on('data', chunk => { output += chunk; if (output.includes('READY')) ready.resolve(); }); + child.on('error', error => ready.resolve(error)); + child.on('close', () => closed.resolve()); + let response; + try { await bounded(ready.promise); response = await f.call('remove_path', { path: path.dirname(a), recursive: true }); } + finally { child.stdin.end('\n'); try { await bounded(closed.promise); } catch { child.kill(); await bounded(closed.promise); } } + const data = failure(response, 'EBUSY'); + assert.equal(data.changed, true); + assert.equal(data.removedCount, 1); + assert.deepEqual(data.partial, [a]); + assert.equal(await fs.readFile(b, 'utf8'), 'LOCKED'); +}); + +test('N4 字面量扩大结果先检查体量,超限不改文件', async t => { + const f = await fixture(t); + const original = 'a'.repeat(20000); + const file = await f.sample('expand.txt', original); + failure(await f.call('edit_file', { path: file, oldString: 'a', newString: 'b'.repeat(1000), replaceAll: true }), 'FILE_TOO_LARGE'); + assert.equal(await fs.readFile(file, 'utf8'), original); +}); + +test('N4 字面量排队时间计入硬预算,取消后可继续编辑', async () => { + const slow = runRegex({ operation: 'edit', pattern: '^(a+)+$', text: 'a'.repeat(32) + '!', replacement: 'x' }).catch(error => error); + await assert.rejects(runLiteral('a', { oldString: 'a', newString: 'b' }, false, { timeoutMs: 100 }), { code: 'TIMEOUT' }); + await slow; + const controller = new AbortController(); + const pending = runLiteral('a', { oldString: 'a', newString: 'b' }, false, { signal: controller.signal }); + controller.abort(); + await assert.rejects(pending, { code: 'CANCELLED' }); + assert.equal((await runLiteral('a', { oldString: 'a', newString: 'b' }, false)).updated, 'b'); +}); + +test('N4 真实stdio字面量replaceAll保持心跳响应并遵守预算', async t => { + const f = await fixture(t); + const original = 'a'.repeat(20000) + 'z'.repeat(500000); + const file = await f.sample('large.txt', original); + await fs.writeFile(f.encryption.cachePath, JSON.stringify(cachedProfile())); + const client = new Client({ name: 'literal-regression', version: '1.0.0' }); + const transport = new StdioClientTransport({ command: process.execPath, args: [path.resolve(__dirname, '../index.js')], cwd: f.root, stderr: 'pipe', env: { ...process.env, MCP_PROFILE_DIR: f.stateDir, MCP_BASE_DIR: f.root, MCP_ALLOWED_ROOTS: JSON.stringify([f.root]) } }); + await client.connect(transport); + f.cleanupTasks.push(() => client.close()); + const begin = performance.now(); + const editing = client.callTool({ name: 'edit_file', arguments: { path: file, oldString: 'a', newString: 'b', replaceAll: true, dryRun: true, timeoutMs: 100 } }).then(response => ({ response, elapsedMs: performance.now() - begin })); + await new Promise(resolve => setTimeout(resolve, 20)); + const pulse = performance.now(); + success(await client.callTool({ name: 'check_status', arguments: {} })); + const heartbeatMs = performance.now() - pulse; + const result = await editing; + assert.ok(['OK', 'TIMEOUT'].includes(result.response.structuredContent.code)); + if (result.response.structuredContent.ok) assert.equal(result.response.structuredContent.data.replaced, 20000); + assert.ok(result.elapsedMs < 1000, '编辑耗时=' + result.elapsedMs); + assert.ok(heartbeatMs < 800, '心跳耗时=' + heartbeatMs); + assert.equal(await fs.readFile(file, 'utf8'), original); + t.diagnostic(JSON.stringify({ inputBytes: original.length, requestedTimeoutMs: 100, code: result.response.structuredContent.code, elapsedMs: result.elapsedMs, heartbeatMs })); +}); diff --git a/test/review-fixes.test.js b/test/review-fixes.test.js new file mode 100644 index 0000000..fd46729 --- /dev/null +++ b/test/review-fixes.test.js @@ -0,0 +1,150 @@ +/** 2026-09-09复核问题R1—R4的正确行为回归;样本及故障注入均隔离。 */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { fixture } = require('./helper.cjs'); +const { fault } = require('../lib/text'); + +/** 验证工具失败状态,并返回结构化数据供文件状态断言使用。 */ +function failed(response, code) { + assert.equal(response.isError, true, JSON.stringify(response)); + if (code) assert.equal(response.structuredContent.code, code); + return response.structuredContent.data; +} + +for (const tool of ['copy_path', 'move_path']) { + test('R1 ' + tool + '拒绝祖先/后代重叠,源与目标均不改变', async t => { + const f = await fixture(t); + const first = await f.sample('tree/tree/z.txt', 'ORIGINAL-UNIQUE'); + const nested = await f.sample('tree/tree/tree/z.txt', 'INNER'); + const source = path.dirname(first); + const before = await fs.readdir(f.root); + const response = await f.call(tool, { source, destination: f.root }); + assert.equal(failed(response, 'OVERLAPPING_PATHS').changed, false); + assert.equal(await fs.readFile(first, 'utf8'), 'ORIGINAL-UNIQUE'); + assert.equal(await fs.readFile(nested, 'utf8'), 'INNER'); + await assert.rejects(fs.access(path.join(f.root, 'tree/z.txt')), { code: 'ENOENT' }); + assert.deepEqual(await fs.readdir(f.root), before); + failed(await f.call(tool, { source, destination: path.join(source, 'new-child') }), 'RECURSIVE_TARGET'); + assert.equal((await f.call(tool, { source, destination: path.dirname(source) })).structuredContent.data.sameFile, true); + assert.equal(await fs.readFile(first, 'utf8'), 'ORIGINAL-UNIQUE'); + }); +} + +for (const installFails of [false, true]) { + test('R2 复制' + (installFails ? '安装前' : '安装后') + '回滚失败保留changed、partial和恢复备份', async t => { + const f = await fixture(t); + const source = await f.sample('source.txt', 'NEW'); + const destination = await f.sample('target.txt', 'ORIGINAL'); + const realRename = fs.rename; + const realVerify = f.encryption.verify; + /** 只对本案例最终目标注入验证失败。 */ + f.encryption.verify = async (file, ...args) => { + if (file === destination) throw fault('DISK_MISMATCH', '模拟最终校验失败'); + return realVerify(file, ...args); + }; + /** 模拟安装或回滚时文件被其他进程占用,不影响其他测试路径。 */ + fs.rename = async (from, to) => { + if (path.dirname(from) === f.root && to === destination) { + if (path.basename(from).startsWith('.mcp-backup-')) throw fault('EBUSY', '模拟回滚失败'); + if (installFails && path.basename(from).startsWith('.mcp-stage-')) throw fault('EACCES', '模拟安装失败'); + } + return realRename(from, to); + }; + let response; + try { response = await f.call('copy_path', { source, destination }); } + finally { fs.rename = realRename; } + const data = failed(response, installFails ? 'EACCES' : 'DISK_MISMATCH'); + assert.equal(response.structuredContent.changed, true); + assert.equal(data.changed, true); + assert.deepEqual(data.partial, [destination]); + assert.equal(data.sourceRetained, true); + assert.ok(data.recoveryPath); + assert.equal(await fs.readFile(data.recoveryPath, 'utf8'), 'ORIGINAL'); + assert.equal(await fs.readFile(source, 'utf8'), 'NEW'); + if (installFails) await assert.rejects(fs.access(destination), { code: 'ENOENT' }); + else assert.equal(await fs.readFile(destination, 'utf8'), 'NEW'); + }); +} + +test('R2 目标原已相同但删源部分完成,失败也必须changed=true', async t => { + const f = await fixture(t); + const a = await f.sample('src/a.txt', 'A'); + const b = await f.sample('src/b.txt', 'B'); + await f.sample('dest/src/a.txt', 'A'); + await f.sample('dest/src/b.txt', 'B'); + const realUnlink = fs.unlink; + let deleted = null; + let retained = null; + /** 仅让第二个源文件删除失败,已存在相同目标无需被改写。 */ + fs.unlink = async file => { + if (file === a || file === b) { + if (deleted) { retained = file; throw fault('EBUSY', '模拟第二个源文件占用'); } + await realUnlink(file); + deleted = file; + return; + } + return realUnlink(file); + }; + let response; + try { response = await f.call('move_path', { source: path.dirname(a), destination: path.join(f.root, 'dest') }); } + finally { fs.unlink = realUnlink; } + const data = failed(response, 'EBUSY'); + assert.equal(data.changed, true); + assert.equal(data.sourceRetained, false); + await assert.rejects(fs.access(deleted), { code: 'ENOENT' }); + await fs.access(retained); + assert.equal(await fs.readFile(path.join(f.root, 'dest/src/a.txt'), 'utf8'), 'A'); + assert.equal(await fs.readFile(path.join(f.root, 'dest/src/b.txt'), 'utf8'), 'B'); +}); + +test('R3 首行无法放入页时明确拒绝,不返回无法前进的成功空页', async t => { + const f = await fixture(t); + for (const size of [399985, 399990, 400000]) { + const file = await f.sample('line.txt', 'X'.repeat(size) + '\n'); + failed(await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 1, endLine: 1 }), 'LINE_TOO_LONG'); + } + const fitting = await f.sample('fitting.txt', 'X'.repeat(399984) + '\n'); + const response = await f.call('read_file_partial', { path: fitting, mode: 'lines', startLine: 1, endLine: 1 }); + assert.ok(!response.isError); + assert.equal(response.structuredContent.data.lines[0].text.length, 399984); + assert.equal(response.structuredContent.data.nextLine, 2); +}); + +test('R3 多行触及预算后游标前进,下一页可正确读取', async t => { + const f = await fixture(t); + const file = await f.sample('pages.txt', 'A'.repeat(200000) + '\n' + 'B'.repeat(200000) + '\n'); + const first = (await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 1, endLine: 2 })).structuredContent; + assert.ok(first.ok); + assert.equal(first.data.lines.length, 1); + assert.equal(first.data.nextLine, 2); + const second = (await f.call('read_file_partial', { path: file, mode: 'lines', startLine: first.data.nextLine, endLine: 2 })).structuredContent; + assert.ok(second.ok); + assert.equal(second.data.lines[0].text, 'B'.repeat(200000)); + assert.ok(second.data.nextLine > first.data.nextLine); +}); + +test('R4 请求第一行不因范围外超长行失败,兼容LF/CRLF/CR', async t => { + const f = await fixture(t); + for (const eol of ['\n', '\r\n', '\r']) { + const file = await f.sample('long-following.txt', 'FIRST' + eol + 'X'.repeat(400002) + eol); + const response = await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 1, endLine: 1 }); + assert.ok(!response.isError, JSON.stringify(response)); + assert.deepEqual(response.structuredContent.data.lines, [{ line: 1, text: 'FIRST' }]); + assert.equal(response.structuredContent.data.nextLine, 2); + assert.equal(response.structuredContent.data.totalLines, null); + } +}); + +test('R4 未提前停止时EOF计数准确,候选续读越界有明确错误', async t => { + const f = await fixture(t); + for (const content of ['', 'FIRST', 'FIRST\n']) { + const file = await f.sample('eof.txt', content); + const page = (await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 1, endLine: 10 })).structuredContent; + assert.ok(page.ok); + assert.equal(page.data.totalLines, 1); + assert.equal(page.data.nextLine, null); + failed(await f.call('read_file_partial', { path: file, mode: 'lines', startLine: 2, endLine: 2 }), 'LINE_OUT_OF_RANGE'); + } +});