diff --git a/plugins/archive-workbench/.gitignore b/plugins/archive-workbench/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/plugins/archive-workbench/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/archive-workbench/CHANGELOG.md b/plugins/archive-workbench/CHANGELOG.md new file mode 100644 index 00000000..ee332826 --- /dev/null +++ b/plugins/archive-workbench/CHANGELOG.md @@ -0,0 +1,10 @@ +# 更新日志 + +## 0.1.0 + +- 新增有界 ZIP 安全预览、预案、创建与解压核心。 +- 为 ZTools 2.4 及以上版本新增只读、绑定界面授权的原生 MCP 检查和规划工具;采用严格分页,并在旧宿主上平稳降级。 +- 将智能体侧 ZIP 读取限制为 64 MiB,共享并发检查,只缓存元数据,并在所有平台拒绝 Windows 非法条目字符。 +- 让根目录源码入口可被 ZTools 直接加载,同时让持续集成继续打包自包含的 `dist` 清单。 +- 将插件界面、状态提示、确认框、文件对话框和安全错误完整本地化为简体中文。 +- 拒绝超过 14.5 MB(14,500,000 字节)的未压缩 `dist`,并输出精确验证字节数。 diff --git a/plugins/archive-workbench/README.md b/plugins/archive-workbench/README.md new file mode 100644 index 00000000..1a794153 --- /dev/null +++ b/plugins/archive-workbench/README.md @@ -0,0 +1,23 @@ +# 压缩包管家 + +压缩包管家是一款保守的 ZIP 管理插件。它会预览中央目录中的全部条目,在写入前生成解压预案,创建简单的不压缩存储 ZIP,并且仅在压缩包与目标目录分别获得授权后执行解压。 + +安全边界会拒绝路径穿越、绝对路径、Windows/UNC 路径、反斜杠、控制字符、Windows 禁止字符与备用数据流冒号、NUL、不支持或加密的条目、使用数据描述符的条目、符号链接、硬链接、设备、FIFO、超大文件、压缩炸弹、Unicode/大小写冲突、Windows 保留名称(包括 Win32 的 `COM¹`–`COM³` 与 `LPT¹`–`LPT³` 别名)以及不安全的上级目录。默认同名策略为 `rename`,绝不会静默覆盖已有文件。 + +本版本只展示 TAR/TGZ 的规划说明,不会用不完整的安全模型接受它们。界面会先预览 ZIP,再单独选择目标目录,并在解压前要求确认;所有 Node.js 文件系统操作都位于最小能力桥之后。插件会拒绝已存在于检查路径中的符号链接,并在关键写入前复查路径;但纯跨平台 Node.js 无法承诺抵御同一账户进程并发替换目录树,因此不能把它视为恶意多进程沙箱。 + +运行 `npm test`、`npm run build`,然后运行 `npm run verify-dist`。Windows、macOS、Linux 上的真实 ZTools 压缩包处理仍待真机验证。 + +## 开发与打包 + +根目录 `plugin.json` 直接指向 `src/ui/index.html`、`preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发项目无需解释 `development` 覆盖即可加载源码包。仓库持续集成仍执行插件的 `build` 脚本并打包 `dist`:源码入口目录没有嵌套清单,而构建会生成自包含的 `dist/plugin.json`,其入口为 `index.html`、`preload/index.cjs` 和 `logo.svg`。 + +`npm run verify-dist` 会递归统计未压缩 `dist` 的大小、打印精确字节数,并在超过 14.5 MB(14,500,000 字节)时失败。 + +## 智能体 / MCP + +ZTools 2.4 及以上版本可向智能体暴露 `inspect_approved_zip` 与 `plan_approved_zip`,完整名称分别为 `archive_workbench_inspect_approved_zip` 与 `archive_workbench_plan_approved_zip`。两个工具均为只读:不能选择路径、接受路径/令牌/授权 ID、解压文件、创建压缩包或执行其他写入。用户必须先在插件界面选择 ZIP;智能体只能访问最近一次成功授权的 ZIP,授权五分钟后过期,离开插件时会立即清除。 + +条目检查与写入预案通过 `offset` 和 `limit` 分页,`limit` 最大为 200。用户界面保留 256 MiB 源文件边界,MCP 检查另设 64 MiB 源文件限制。同一授权的并发检查和规划调用共享一次读取与安全检查;缓存只保存有界条目元数据,不保存 ZIP 字节。后续使用缓存前会重新验证已授权路径、设备、inode、大小、修改时间和状态变更时间(`ctimeMs`),防止原地改写复用过期元数据。响应包含总量、安全限制、分页状态与所选冲突策略,但不会包含绝对路径或授权令牌。`plan_approved_zip` 只描述预期的相对写入,不会选择目标目录或执行预案。 + +ZTools MCP 传输层接受最大 1 MiB 请求体,但不会强制执行每个工具的 JSON Schema,也不会限制响应。preload 因此会独立拒绝未知字段、路径/令牌形态字段、恶意或自定义原型、访问器、错误类型、无效冲突模式及越界分页,并将序列化响应限制为 128 KiB。没有 `registerTool` 的旧宿主仍可使用完整的人类界面。Windows、macOS、Linux 上的真实压缩包处理仍待真机验证。 diff --git a/plugins/archive-workbench/logo.svg b/plugins/archive-workbench/logo.svg new file mode 100644 index 00000000..bd3c42cd --- /dev/null +++ b/plugins/archive-workbench/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/archive-workbench/package-lock.json b/plugins/archive-workbench/package-lock.json new file mode 100644 index 00000000..7448cbaa --- /dev/null +++ b/plugins/archive-workbench/package-lock.json @@ -0,0 +1 @@ +{"name":"archive-workbench","version":"0.1.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"archive-workbench","version":"0.1.0","engines":{"node":">=16"}}}} diff --git a/plugins/archive-workbench/package.json b/plugins/archive-workbench/package.json new file mode 100644 index 00000000..0c1be79a --- /dev/null +++ b/plugins/archive-workbench/package.json @@ -0,0 +1,14 @@ +{ + "name": "archive-workbench", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "engines": { + "node": ">=16" + }, + "scripts": { + "test": "node --test", + "build": "node --test && node scripts/build.mjs && node scripts/verify-dist.mjs", + "verify-dist": "node scripts/verify-dist.mjs" + } +} diff --git a/plugins/archive-workbench/plugin.json b/plugins/archive-workbench/plugin.json new file mode 100644 index 00000000..7f067ffd --- /dev/null +++ b/plugins/archive-workbench/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "archive-workbench", + "title": "压缩包管家", + "version": "0.1.0", + "description": "检查 ZIP 安全性,预先规划并在受限边界内创建或解压文件。", + "author": "harris", + "main": "src/ui/index.html", + "logo": "logo.svg", + "preload": "preload/index.cjs", + "platform": ["darwin", "win32", "linux"], + "categories": ["productivity", "system"], + "features": [{"code": "archive-safety", "explain": "预览所选 ZIP 并准备安全的解压预案。", "icon": "logo.svg", "cmds": ["压缩包预览", "安全解压"]}], + "tools": { + "inspect_approved_zip": { + "title": "检查已授权 ZIP", + "description": "只读检查用户最近在插件界面选择且尚未过期的 ZIP,并分页返回安全条目。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "offset": {"type": "integer", "minimum": 0, "maximum": 1200}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200} + } + } + }, + "plan_approved_zip": { + "title": "规划已授权 ZIP", + "description": "只读规划用户最近在插件界面选择且尚未过期的 ZIP;不会创建目录或写入文件。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "offset": {"type": "integer", "minimum": 0, "maximum": 1200}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200}, + "conflict": {"type": "string", "enum": ["rename", "skip", "error"]} + } + } + } + } +} diff --git a/plugins/archive-workbench/preload/index.cjs b/plugins/archive-workbench/preload/index.cjs new file mode 100644 index 00000000..c259c4e6 --- /dev/null +++ b/plugins/archive-workbench/preload/index.cjs @@ -0,0 +1,603 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const fsSync = require('node:fs'); +const path = require('node:path'); +const { randomBytes } = require('node:crypto'); +const { pathToFileURL } = require('node:url'); + +const TOOL_NAMES = Object.freeze({ inspect: 'inspect_approved_zip', plan: 'plan_approved_zip' }); +const GRANT_TTL_MS = 5 * 60 * 1000; +const ZIP_SOURCE_MAX = 256 * 1024 * 1024; +const MCP_ZIP_SOURCE_MAX = 64 * 1024 * 1024; +const CREATE_FILE_MAX = 64 * 1024 * 1024; +const CREATE_TOTAL_MAX = 256 * 1024 * 1024; +const CREATE_COUNT_MAX = 100; +const MAX_GRANTS = 32; +const MCP_MAX_OFFSET = 1200; +const MCP_MAX_LIMIT = 200; +const MCP_DEFAULT_LIMIT = 100; +const MCP_RESPONSE_BYTES = 128 * 1024; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +const selected = new Map(); +const registeredHosts = new WeakSet(); +const lifecycleHosts = new WeakSet(); +let activeZipToken = null; +let corePromise; +let mcpCache = null; +let mcpFlight = null; +let sessionEpoch = 0; +const mcpMetrics = { reads: 0, inspections: 0 }; + +function invalid(message) { + return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); +} + +function approvalRequired() { + return Object.assign(new Error('当前没有用户授权的 ZIP,请先在插件界面选择 ZIP 后再试。'), { code: 'APPROVED_ZIP_REQUIRED' }); +} + +function mcpZipTooLarge() { + return Object.assign(new Error('已授权 ZIP 超过 MCP 检查的 64 MiB 限制;更大的压缩包请使用插件界面。'), { code: 'MCP_ZIP_TOO_LARGE' }); +} + +function sessionExpired() { + return Object.assign(new Error('当前插件会话已结束,请重新选择文件或目录。'), { code: 'SESSION_EXPIRED' }); +} + +function assertSessionEpoch(epoch) { + if (epoch !== sessionEpoch) throw sessionExpired(); +} + +function clearMcpState(grantId) { + if (grantId === undefined || mcpCache?.grantId === grantId) mcpCache = null; + if (grantId === undefined || mcpFlight?.grantId === grantId) mcpFlight = null; +} + +function clearExpired(now = Date.now()) { + for (const [id, item] of selected) { + if (item.expires <= now) { + selected.delete(id); + if (activeZipToken === id) activeZipToken = null; + clearMcpState(id); + } + } +} + +function clearGrants() { + sessionEpoch += 1; + selected.clear(); + activeZipToken = null; + clearMcpState(); +} + +function revokeZip(id) { + selected.delete(id); + if (activeZipToken === id) activeZipToken = null; + clearMcpState(id); +} + +function token(kind, value, epoch = sessionEpoch) { + assertSessionEpoch(epoch); + clearExpired(); + if (selected.size >= MAX_GRANTS) throw new Error('当前文件授权过多,请退出并重新打开插件。'); + const id = randomBytes(24).toString('base64url'); + selected.set(id, { kind, value, expires: Date.now() + GRANT_TTL_MS }); + return id; +} + +function activateZip(value, epoch = sessionEpoch) { + assertSessionEpoch(epoch); + clearExpired(); + if (activeZipToken) revokeZip(activeZipToken); + const id = token('zip', value, epoch); + activeZipToken = id; + clearMcpState(); + return id; +} + +function grant(id, kind, epoch = sessionEpoch) { + assertSessionEpoch(epoch); + clearExpired(); + const grantId = String(id); + const item = selected.get(grantId); + if (!item || item.kind !== kind) throw new Error('所选路径的授权无效或已过期,请重新选择。'); + return Object.freeze({ id: grantId, kind, value: item.value }); +} + +function assertGrant(record, epoch) { + assertSessionEpoch(epoch); + clearExpired(); + const current = selected.get(record.id); + if (!current || current.kind !== record.kind || current.value !== record.value) { + throw new Error('所选路径的授权无效或已过期,请重新选择。'); + } + return record.value; +} + +function activeZipGrant() { + clearExpired(); + if (!activeZipToken) throw approvalRequired(); + const item = selected.get(activeZipToken); + if (!item || item.kind !== 'zip') { + clearMcpState(activeZipToken); + activeZipToken = null; + throw approvalRequired(); + } + return { id: activeZipToken, value: item.value, epoch: sessionEpoch }; +} + +function pathsFrom(result) { + if (typeof result === 'string') return [result]; + if (Array.isArray(result)) return result; + return Array.isArray(result && result.filePaths) ? result.filePaths : []; +} + +async function choose(api, options) { + if (typeof api.showOpenDialog !== 'function') throw new Error('当前 ZTools 版本不支持文件选择对话框。'); + const values = pathsFrom(await api.showOpenDialog(options)); + if (!values.length) throw new Error('已取消选择。'); + return values; +} + +async function save(api, options) { + if (typeof api.showSaveDialog !== 'function') throw new Error('当前 ZTools 版本不支持保存对话框。'); + const result = await api.showSaveDialog(options); + const value = typeof result === 'string' ? result : result && (result.filePath || result.path); + if (!value) throw new Error('已取消保存。'); + return value; +} + +async function core() { + if (!corePromise) { + const packaged = path.join(__dirname, '..', 'core', 'archive.mjs'); + const source = path.join(__dirname, '..', 'src', 'core', 'archive.mjs'); + corePromise = import(pathToFileURL(fsSync.existsSync(packaged) ? packaged : source).href); + } + return corePromise; +} + +function summary(plan) { + return { + format: plan.format, + entries: plan.entries.map((entry) => ({ name: entry.name, size: entry.size, compressed: entry.compressed })), + total: plan.total, + conflict: plan.conflict + }; +} + +async function safeFile(input, maxSize, extension) { + const initial = await fs.lstat(input); + if (initial.isSymbolicLink() || !initial.isFile()) throw new Error('只能使用直接选择的普通文件。'); + const resolved = await fs.realpath(input); + const final = await fs.lstat(resolved); + if (final.isSymbolicLink() || !final.isFile() || final.size > maxSize) throw new Error('所选文件不安全或超过大小限制。'); + if (extension && path.extname(resolved).toLowerCase() !== extension) throw new Error(`请选择 ${extension} 文件。`); + return fileIdentity(resolved, final); +} + +function fileIdentity(filePath, info) { + return Object.freeze({ path: filePath, size: info.size, dev: info.dev, ino: info.ino, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs }); +} + +function sameFileIdentity(approved, current) { + return approved.path === current.path + && approved.dev === current.dev + && approved.ino === current.ino + && approved.mtimeMs === current.mtimeMs + && approved.ctimeMs === current.ctimeMs + && approved.size === current.size; +} + +async function readHandleBounded(handle, limit, maximum = MCP_ZIP_SOURCE_MAX) { + if (!Number.isSafeInteger(limit) || limit < 0 || !Number.isSafeInteger(maximum) || maximum < 0 || limit > maximum) throw new RangeError('已授权文件的读取限制无效。'); + const buffer = Buffer.allocUnsafe(limit + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > limit) throw new RangeError('已授权文件的大小在选择后增大。'); + return buffer.subarray(0, offset); +} + +async function readApprovedIdentity(approved, maximum, extension) { + const current = await safeFile(approved.path, maximum, extension); + if (!sameFileIdentity(approved, current)) throw new Error('所选文件的身份已发生变化。'); + const flags = fsSync.constants.O_RDONLY | (process.platform === 'win32' ? 0 : (fsSync.constants.O_NOFOLLOW || 0)); + let handle; + try { + handle = await fs.open(current.path, flags); + const before = await handle.stat(); + if (!before.isFile() || !sameFileIdentity(approved, fileIdentity(current.path, before))) throw new Error('所选文件在打开时发生变化。'); + const bytes = await readHandleBounded(handle, approved.size, maximum); + const after = await handle.stat(); + if (!sameFileIdentity(approved, fileIdentity(current.path, after))) throw new Error('所选文件在读取时发生变化。'); + const final = await safeFile(current.path, maximum, extension); + if (!sameFileIdentity(approved, final) || bytes.length !== approved.size) throw new Error('所选文件在读取时发生变化。'); + return bytes; + } finally { + await handle?.close().catch(() => {}); + } +} + +async function safeDirectory(input) { + const selectedPath = path.resolve(input); + const initial = await fs.lstat(selectedPath); + if (initial.isSymbolicLink() || !initial.isDirectory()) throw new Error('解压位置必须是真实目录。'); + const resolved = await fs.realpath(selectedPath); + const final = await fs.lstat(resolved); + if (final.isSymbolicLink() || !final.isDirectory()) throw new Error('解压位置在选择后发生变化。'); + let current = path.parse(resolved).root; + for (const part of path.relative(current, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + const info = await fs.lstat(current); + if (info.isSymbolicLink()) throw new Error('解压位置的上级路径包含符号链接。'); + } + return resolved; +} + +function validateObject(value, allowed, label = '工具输入') { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(`${label}必须是对象。`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw invalid(`${label}包含不支持的原型。`); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key) || !allowed.has(key)) throw invalid(`${label}包含不支持的字段。`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw invalid(`${label}只能包含数据字段。`); + } +} + +function boundedInteger(value, field, fallback, minimum, maximum) { + if (value === undefined) return fallback; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw invalid(`${field} 必须是 ${minimum} 到 ${maximum} 之间的整数。`); + return value; +} + +function own(value, key) { + return Object.hasOwn(value, key) ? value[key] : undefined; +} + +function validateToolInput(input = {}, allowConflict = false) { + validateObject(input, new Set(['offset', 'limit', ...(allowConflict ? ['conflict'] : [])])); + const offset = boundedInteger(own(input, 'offset'), 'offset', 0, 0, MCP_MAX_OFFSET); + const limit = boundedInteger(own(input, 'limit'), 'limit', MCP_DEFAULT_LIMIT, 1, MCP_MAX_LIMIT); + const conflictValue = own(input, 'conflict'); + const conflict = conflictValue === undefined ? 'rename' : conflictValue; + if (allowConflict && !['rename', 'skip', 'error'].includes(conflict)) throw invalid('conflict 必须是 rename、skip 或 error。'); + return { offset, limit, ...(allowConflict ? { conflict } : {}) }; +} + +function safeLimits(plan) { + return { + archiveEntries: Number(plan.limits.entries), + singleExpandedBytes: Number(plan.limits.single), + totalExpandedBytes: Number(plan.limits.total), + compressionRatio: Number(plan.limits.ratio), + pathDepth: Number(plan.limits.depth), + entryNameBytes: Number(plan.limits.name), + sourceBytes: MCP_ZIP_SOURCE_MAX, + pageLimit: MCP_MAX_LIMIT, + responseBytes: MCP_RESPONSE_BYTES + }; +} + +function totals(plan) { + return { + entries: plan.entries.length, + expandedBytes: plan.total, + compressedBytes: plan.entries.reduce((sum, entry) => sum + entry.compressed, 0) + }; +} + +function encodedBytes(value) { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function pagedResponse(base, key, all, offset, limit, mapItem) { + const requested = all.slice(offset, offset + limit).map(mapItem); + const items = requested.slice(); + const response = { + ...base, + pagination: { offset, limit, returned: items.length, hasMore: offset + items.length < all.length, responseTruncated: false }, + [key]: items + }; + while (items.length && encodedBytes(response) > MCP_RESPONSE_BYTES) { + items.pop(); + response.pagination.returned = items.length; + response.pagination.hasMore = offset + items.length < all.length; + response.pagination.responseTruncated = true; + } + if (encodedBytes(response) > MCP_RESPONSE_BYTES) throw new Error('安全压缩包摘要超过 MCP 响应大小限制。'); + return response; +} + +function approvedZipUnavailable(record) { + revokeZip(record.id); + return Object.assign(new Error('已授权 ZIP 不可用或不再安全,请在插件界面重新选择。'), { code: 'APPROVED_ZIP_UNAVAILABLE' }); +} + +async function revalidateApprovedZip(record) { + try { + const current = await safeFile(record.value.path, MCP_ZIP_SOURCE_MAX, '.zip'); + if (!sameFileIdentity(record.value, current)) throw new Error('已授权 ZIP 的文件身份已发生变化。'); + return current; + } catch { + if (!activeGrantStillMatches(record.id, record.epoch)) throw approvalRequired(); + throw approvedZipUnavailable(record); + } +} + +async function readApprovedZip(record) { + let handle; + try { + const current = await revalidateApprovedZip(record); + handle = await fs.open(current.path, 'r'); + const before = await handle.stat(); + const opened = fileIdentity(current.path, before); + if (!before.isFile() || !sameFileIdentity(record.value, opened)) throw new Error('已授权 ZIP 在打开时发生变化。'); + mcpMetrics.reads += 1; + const bytes = await readHandleBounded(handle, record.value.size); + const after = await handle.stat(); + const finished = fileIdentity(current.path, after); + if (!sameFileIdentity(record.value, finished) || bytes.length !== record.value.size) throw new Error('已授权 ZIP 在读取时发生变化。'); + return bytes; + } catch (error) { + if (!activeGrantStillMatches(record.id, record.epoch)) throw approvalRequired(); + if (error?.code === 'APPROVED_ZIP_UNAVAILABLE') throw error; + throw approvedZipUnavailable(record); + } finally { + await handle?.close().catch(() => {}); + } +} + +function cacheableArchive(plan) { + const entries = Object.freeze(plan.entries.map((entry) => Object.freeze({ + name: entry.name, + size: entry.size, + compressed: entry.compressed, + directory: entry.directory, + method: entry.method + }))); + return Object.freeze({ format: plan.format, entries, total: plan.total, limits: Object.freeze({ ...plan.limits }) }); +} + +function cachedFailure() { + return Object.assign(new Error('已授权 ZIP 未通过受限安全检查。'), { code: 'ZIP_INSPECTION_FAILED' }); +} + +function activeGrantStillMatches(id, epoch = sessionEpoch) { + clearExpired(); + return epoch === sessionEpoch && activeZipToken === id && selected.get(id)?.kind === 'zip'; +} + +async function mcpInspection() { + const record = activeZipGrant(); + if (record.value.size > MCP_ZIP_SOURCE_MAX) throw mcpZipTooLarge(); + + if (mcpCache?.grantId === record.id) { + await revalidateApprovedZip(record); + if (!activeGrantStillMatches(record.id, record.epoch)) throw approvalRequired(); + if (mcpCache.error) throw cachedFailure(); + return mcpCache.archive; + } + if (mcpFlight?.grantId === record.id) return mcpFlight.promise; + + const flight = { grantId: record.id, promise: null }; + flight.promise = (async () => { + const bytes = await readApprovedZip(record); + let archive; + try { + const mod = await core(); + mcpMetrics.inspections += 1; + archive = cacheableArchive(mod.inspectZip(bytes)); + } catch { + if (activeGrantStillMatches(record.id, record.epoch)) mcpCache = { grantId: record.id, error: true }; + throw cachedFailure(); + } + if (!activeGrantStillMatches(record.id, record.epoch)) throw approvalRequired(); + mcpCache = { grantId: record.id, archive }; + return archive; + })().finally(() => { + if (mcpFlight === flight) mcpFlight = null; + }); + mcpFlight = flight; + return flight.promise; +} + +async function inspectApprovedZipForMcp(input = {}) { + const page = validateToolInput(input, false); + const plan = await mcpInspection(); + return pagedResponse( + { format: plan.format, conflict: null, totals: totals(plan), limits: safeLimits(plan) }, + 'entries', + plan.entries, + page.offset, + page.limit, + (entry) => ({ name: entry.name, size: entry.size, compressed: entry.compressed, directory: entry.directory, method: entry.method }) + ); +} + +async function planApprovedZipForMcp(input = {}) { + const page = validateToolInput(input, true); + const archive = await mcpInspection(); + const action = page.conflict === 'rename' ? 'rename-on-conflict' : `write-or-${page.conflict}`; + return pagedResponse( + { format: archive.format, conflict: page.conflict, totals: totals(archive), limits: safeLimits(archive) }, + 'writePlan', + archive.entries, + page.offset, + page.limit, + (entry) => ({ name: entry.name, action }) + ); +} + +function registerTools(target) { + const api = target?.ztools; + if (!api || typeof api.registerTool !== 'function' || registeredHosts.has(api)) return Object.freeze([]); + const registered = []; + for (const [name, handler] of [[TOOL_NAMES.inspect, inspectApprovedZipForMcp], [TOOL_NAMES.plan, planApprovedZipForMcp]]) { + try { api.registerTool.call(api, name, handler); registered.push(name); } catch {} + } + registeredHosts.add(api); + return Object.freeze(registered); +} + +function registerLifecycle(api) { + if (!api || (typeof api !== 'object' && typeof api !== 'function') || lifecycleHosts.has(api)) return false; + try { + if (typeof api.onPluginOut === 'function') api.onPluginOut(clearGrants); + } catch {} + lifecycleHosts.add(api); + return true; +} + +function createBridge(api) { + return Object.freeze({ + chooseZip: async () => { + const epoch = sessionEpoch; + const input = (await choose(api, { title: '选择 ZIP 压缩包', properties: ['openFile'], filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }] }))[0]; + assertSessionEpoch(epoch); + const approved = await safeFile(input, ZIP_SOURCE_MAX, '.zip'); + assertSessionEpoch(epoch); + return activateZip(approved, epoch); + }, + chooseDestination: async () => { + const epoch = sessionEpoch; + const input = (await choose(api, { title: '选择解压位置', properties: ['openDirectory', 'createDirectory'] }))[0]; + assertSessionEpoch(epoch); + const approved = await safeDirectory(input); + assertSessionEpoch(epoch); + return token('destination', approved, epoch); + }, + chooseFiles: async () => { + const epoch = sessionEpoch; + const inputs = await choose(api, { title: '选择要压缩的文件', properties: ['openFile', 'multiSelections'] }); + assertSessionEpoch(epoch); + if (inputs.length > CREATE_COUNT_MAX) throw new Error('最多选择 100 个文件。'); + let total = 0; + const safe = []; + for (const input of inputs) { + const item = await safeFile(input, CREATE_FILE_MAX); + assertSessionEpoch(epoch); + total += item.size; + if (total > CREATE_TOTAL_MAX) throw new Error('所选文件超过 256 MiB 总大小限制。'); + safe.push(item); + } + return token('files', safe, epoch); + }, + preview: async (zipToken) => { + const epoch = sessionEpoch; + const zip = grant(zipToken, 'zip', epoch); + const mod = await core(); + assertGrant(zip, epoch); + const bytes = await readApprovedIdentity(zip.value, ZIP_SOURCE_MAX, '.zip'); + assertGrant(zip, epoch); + return summary(mod.planExtraction(bytes, { conflict: 'rename' })); + }, + extract: async (zipToken, destinationToken) => { + const epoch = sessionEpoch; + const zip = grant(zipToken, 'zip', epoch); + const destination = grant(destinationToken, 'destination', epoch); + const destinationPath = await safeDirectory(destination.value); + assertGrant(destination, epoch); + const mod = await core(); + assertGrant(zip, epoch); + assertGrant(destination, epoch); + const bytes = await readApprovedIdentity(zip.value, ZIP_SOURCE_MAX, '.zip'); + assertGrant(zip, epoch); + assertGrant(destination, epoch); + const assertActive = () => { + assertGrant(zip, epoch); + assertGrant(destination, epoch); + }; + return summary(await mod.extractZipSafely(bytes, destinationPath, { conflict: 'rename', assertActive })); + }, + create: async (filesToken) => { + const epoch = sessionEpoch; + const files = grant(filesToken, 'files', epoch); + const inputs = files.value; + if (!Array.isArray(inputs) || !inputs.length || inputs.length > CREATE_COUNT_MAX) throw new Error('文件选择授权已失效,请重新选择。'); + const mod = await core(); + assertGrant(files, epoch); + let total = 0; + const entries = []; + for (const input of inputs) { + const bytes = await readApprovedIdentity(input, CREATE_FILE_MAX); + assertGrant(files, epoch); + total += input.size; + if (total > CREATE_TOTAL_MAX) throw new Error('所选文件超过 256 MiB 总大小限制。'); + entries.push({ name: path.basename(input.path), data: bytes }); + } + const bytes = mod.createStoredZip(entries); + assertGrant(files, epoch); + const target = await save(api, { title: '保存 ZIP 压缩包', defaultPath: '压缩包.zip', filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }] }); + assertGrant(files, epoch); + try { + await fs.lstat(target); + throw new Error('不能覆盖已有文件,请选择新的文件名。'); + } catch (error) { + if (error && error.code !== 'ENOENT') throw error; + } + assertGrant(files, epoch); + const temporary = path.join(path.dirname(target), `.archive-workbench-${randomBytes(12).toString('hex')}.tmp`); + let temporaryIdentity; + let linked = false; + try { + await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' }); + assertGrant(files, epoch); + temporaryIdentity = fileIdentity(temporary, await fs.lstat(temporary)); + assertGrant(files, epoch); + await fs.link(temporary, target); + linked = true; + assertGrant(files, epoch); + await fs.rm(temporary, { force: true }); + assertGrant(files, epoch); + } catch (error) { + if (linked && temporaryIdentity) { + try { + const output = fileIdentity(target, await fs.lstat(target)); + if (temporaryIdentity.dev === output.dev && temporaryIdentity.ino === output.ino) await fs.rm(target, { force: true }); + } catch {} + } + await fs.rm(temporary, { force: true }).catch(() => {}); + throw error; + } + return { path: target, entries: entries.length }; + }, + copyText: typeof api.copyText === 'function' ? (text) => api.copyText(String(text)) : undefined + }); +} + +function attachArchiveWorkbench(target) { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('必须提供类似 window 的目标对象。'); + const api = target.ztools || {}; + const bridge = createBridge(api); + Object.defineProperty(target, 'archiveWorkbench', { value: bridge, enumerable: true, configurable: true, writable: true }); + registerLifecycle(api); + registerTools(target); + return bridge; +} + +if (typeof globalThis !== 'undefined') attachArchiveWorkbench(globalThis); + +module.exports = { + TOOL_NAMES, + GRANT_TTL_MS, + MCP_ZIP_SOURCE_MAX, + MCP_MAX_OFFSET, + MCP_MAX_LIMIT, + MCP_RESPONSE_BYTES, + readHandleBounded, + validateObject, + validateToolInput, + inspectApprovedZipForMcp, + planApprovedZipForMcp, + registerTools, + attachArchiveWorkbench, + __testClearGrants: clearGrants, + __testMcpMetrics: () => ({ ...mcpMetrics }), + __testActiveZipIdentity: () => selected.get(activeZipToken)?.value || null +}; diff --git a/plugins/archive-workbench/preload/package.json b/plugins/archive-workbench/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/archive-workbench/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/archive-workbench/scripts/build.mjs b/plugins/archive-workbench/scripts/build.mjs new file mode 100644 index 00000000..e565432a --- /dev/null +++ b/plugins/archive-workbench/scripts/build.mjs @@ -0,0 +1 @@ +import {cp,mkdir,readFile,rm,writeFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),dist=path.join(root,'dist');await rm(dist,{recursive:true,force:true});await mkdir(dist,{recursive:true});await cp(path.join(root,'src','ui'),dist,{recursive:true});await cp(path.join(root,'src','core'),path.join(dist,'core'),{recursive:true});await cp(path.join(root,'preload'),path.join(dist,'preload'),{recursive:true});await cp(path.join(root,'logo.svg'),path.join(dist,'logo.svg'));const app=await readFile(path.join(dist,'app.mjs'),'utf8');await writeFile(path.join(dist,'app.mjs'),app.replace("'../core/archive.mjs'", "'./core/archive.mjs'"));const m=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8'));delete m.development;m.main='index.html';m.logo='logo.svg';m.preload='preload/index.cjs';await writeFile(path.join(dist,'plugin.json'),JSON.stringify(m,null,2)+'\n'); diff --git a/plugins/archive-workbench/scripts/dist-size.mjs b/plugins/archive-workbench/scripts/dist-size.mjs new file mode 100644 index 00000000..30964eda --- /dev/null +++ b/plugins/archive-workbench/scripts/dist-size.mjs @@ -0,0 +1,39 @@ +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +export const DIST_SIZE_LIMIT_BYTES = 14_500_000; + +export function assertWithinDistSizeLimit(bytes, limit = DIST_SIZE_LIMIT_BYTES) { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist byte count must be a non-negative safe integer'); + if (!Number.isSafeInteger(limit) || limit < 0) throw new TypeError('dist size limit must be a non-negative safe integer'); + if (bytes > limit) throw new Error(`dist is ${bytes} bytes and exceeds the 14.5 MB safety limit (${limit} bytes)`); + return bytes; +} + +export async function directoryBytes(directory, options = {}) { + const { + baseDirectory = directory, + readEntries = readdir, + inspectEntry = lstat + } = options; + let total = 0; + + for (const entry of await readEntries(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const metadata = await inspectEntry(entryPath); + const relative = path.relative(baseDirectory, entryPath) || entry.name; + + if (metadata.isSymbolicLink()) throw new Error(`Unsupported dist symbolic link: ${relative}`); + if (metadata.isDirectory()) { + total += await directoryBytes(entryPath, { baseDirectory, readEntries, inspectEntry }); + } else if (metadata.isFile()) { + if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) throw new Error(`Invalid dist file size: ${relative}`); + total += metadata.size; + if (!Number.isSafeInteger(total)) throw new Error('dist byte count exceeds the safe integer range'); + } else { + throw new Error(`Unsupported dist special file: ${relative}`); + } + } + + return total; +} diff --git a/plugins/archive-workbench/scripts/verify-dist.mjs b/plugins/archive-workbench/scripts/verify-dist.mjs new file mode 100644 index 00000000..f6c0b669 --- /dev/null +++ b/plugins/archive-workbench/scripts/verify-dist.mjs @@ -0,0 +1,23 @@ +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertWithinDistSizeLimit, directoryBytes } from './dist-size.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dist = path.join(root, 'dist'); + +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')); +if (manifest.development) throw new Error('development leaked'); +for (const file of [manifest.main, manifest.logo, manifest.preload, 'core/archive.mjs']) await access(path.join(dist, file)); +const expectedTools = ['inspect_approved_zip', 'plan_approved_zip']; +if (JSON.stringify(Object.keys(manifest.tools || {}).sort()) !== JSON.stringify(expectedTools)) throw new Error('MCP tool declarations are missing or unexpected'); +for (const name of expectedTools) { + const schema = manifest.tools[name]?.inputSchema; + if (!schema || schema.type !== 'object' || schema.additionalProperties !== false) throw new Error(`MCP tool ${name} is not strict`); +} +if ((await readFile(path.join(dist, 'app.mjs'), 'utf8')).includes('../core/')) throw new Error('dist UI import escaped package'); +if (await readFile(path.join(root, 'preload', 'index.cjs'), 'utf8') !== await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8')) throw new Error('dist preload is stale'); + +const distBytes = await directoryBytes(dist); +assertWithinDistSizeLimit(distBytes); +console.log(`archive-workbench dist verified (${distBytes} bytes)`); diff --git a/plugins/archive-workbench/src/core/archive.mjs b/plugins/archive-workbench/src/core/archive.mjs new file mode 100644 index 00000000..f73b8054 --- /dev/null +++ b/plugins/archive-workbench/src/core/archive.mjs @@ -0,0 +1,178 @@ +import { inflateRawSync } from 'node:zlib'; +import { randomBytes } from 'node:crypto'; +import { link, lstat, mkdir, realpath, rm, rmdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +const LIMITS = Object.freeze({ entries: 1200, single: 64 * 1024 * 1024, total: 256 * 1024 * 1024, ratio: 120, depth: 20, name: 240 }); +const textDecoder = new TextDecoder('utf-8', { fatal: true }); +function fail(message) { throw new Error(message); } +function u16(v, at) { if(at+2>v.byteLength) fail('ZIP 记录被截断。'); return v.getUint16(at,true); } +function u32(v, at) { if(at+4>v.byteLength) fail('ZIP 记录被截断。'); return v.getUint32(at,true); } +function reservedWindows(segment) { return /^(?:con|prn|aux|nul|com(?:[1-9]|[¹²³])|lpt(?:[1-9]|[¹²³]))(?:\..*)?$/i.test(segment) || /[. ]$/.test(segment) || /[\u0000-\u001f<>:"|?*]/.test(segment); } +export function normalizeEntryName(name) { + if(typeof name!=='string'||!name||name.includes('\0')||new TextEncoder().encode(name).byteLength>LIMITS.name) fail('条目名称无效。'); + if(name.includes('\\')||/^\//.test(name)||/^(?:[A-Za-z]:|\\\\)/.test(name)) fail(`压缩包路径不安全:${name}`); + const directory=name.endsWith('/'), raw=directory?name.slice(0,-1):name; const parts=raw.normalize('NFC').split('/'); if(parts.some((x)=>!x||x==='.'||x==='..'||reservedWindows(x))) fail(`压缩包路径不安全:${name}`); if(parts.length>LIMITS.depth) fail('压缩包路径超过深度限制。'); return parts.join('/')+(directory?'/':''); +} +export function collisionKey(name) { return normalizeEntryName(name).replace(/\/$/,'').normalize('NFC').toLocaleLowerCase('en-US'); } +function findEocd(bytes) { const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength); for(let p=bytes.length-22;p>=Math.max(0,bytes.length-65557);p-=1) if(u32(view,p)===0x06054b50) return p; fail('未找到 ZIP 结束记录。'); } +function decode(bytes) { try{return textDecoder.decode(bytes)}catch{fail('ZIP 文件名不是有效的 UTF-8。')} } +export function inspectZip(input, options={}) { + const bytes=input instanceof Uint8Array?input:new Uint8Array(input); const limits={...LIMITS,...(options.limits||{})}; const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength); const eocd=findEocd(bytes); const disk=u16(view,eocd+4),cdDisk=u16(view,eocd+6),entriesDisk=u16(view,eocd+8),entriesCount=u16(view,eocd+10),cdSize=u32(view,eocd+12),cdOffset=u32(view,eocd+16),comment=u16(view,eocd+20); if(disk||cdDisk||entriesDisk!==entriesCount||entriesCount===0xffff||cdSize===0xffffffff||cdOffset===0xffffffff||eocd+22+comment!==bytes.length) fail('拒绝多磁盘、ZIP64 或格式错误的 ZIP 结束记录。'); if(entriesCount>limits.entries||cdOffset+cdSize>eocd) fail('ZIP 中央目录超过安全限制。'); + let at=cdOffset,total=0;const entries=[],seen=new Set(); for(let i=0;icdOffset+cdSize||u32(view,at)!==0x02014b50)fail('ZIP 中央目录条目无效。');const flags=u16(view,at+8),method=u16(view,at+10),crc=u32(view,at+16),compressed=u32(view,at+20),size=u32(view,at+24),nameLength=u16(view,at+28),extra=u16(view,at+30),entryComment=u16(view,at+32),attrs=u32(view,at+38),offset=u32(view,at+42),end=at+46+nameLength+extra+entryComment;if(end>cdOffset+cdSize||compressed===0xffffffff||size===0xffffffff||offset===0xffffffff)fail('拒绝 ZIP64 或格式错误的中央目录条目。');if(flags&1||flags&8)fail('拒绝加密或使用数据描述符的 ZIP 条目。');if(![0,8].includes(method))fail('不支持此 ZIP 压缩方法。');if((method===0&&size>0&&compressed===0)||size>limits.single||(compressed&&size/compressed>limits.ratio))fail('ZIP 条目超过解压安全限制。');const name=normalizeEntryName(decode(bytes.subarray(at+46,at+46+nameLength)));const key=collisionKey(name);if(seen.has(key))fail(`存在大小写或 Unicode 冲突:${name}`);seen.add(key);const mode=attrs>>>16,type=mode&0o170000,directory=name.endsWith('/');if(type&&type!==0o100000&&type!==0o40000)fail(`压缩包包含不安全的特殊条目:${name}`);if((type===0o40000&&!directory)||(directory&&(size!==0||compressed!==0||method!==0)))fail(`压缩包包含不安全的目录条目:${name}`);total+=size;if(total>limits.total)fail('ZIP 解压后总大小超过安全限制。');entries.push({name,key,flags,method,crc,compressed,size,offset,attrs,directory,centralOffset:cdOffset});at=end;}if(at!==cdOffset+cdSize)fail('ZIP 中央目录大小与条目不匹配。');for(const entry of entries)validateLocalRecord(bytes,entry,cdOffset); + return {format:'zip',entries,total,limits}; +} +function validateLocalRecord(bytes, entry, centralOffset) { const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),at=entry.offset;if(at>=centralOffset||u32(view,at)!==0x04034b50)fail(`条目缺少本地文件头:${entry.name}。`);const flags=u16(view,at+6),method=u16(view,at+8),crc=u32(view,at+14),compressed=u32(view,at+18),size=u32(view,at+22),nameLength=u16(view,at+26),extra=u16(view,at+28),start=at+30+nameLength+extra,end=start+entry.compressed;if(flags!==entry.flags||method!==entry.method||crc!==entry.crc||compressed!==entry.compressed||size!==entry.size||start>centralOffset||end>centralOffset)fail(`条目的本地文件头不匹配:${entry.name}。`);const localName=normalizeEntryName(decode(bytes.subarray(at+30,at+30+nameLength)));if(localName!==entry.name)fail(`条目的本地文件名不匹配:${entry.name}。`);return { start, end }; } +function localData(bytes, entry) { const {start,end}=validateLocalRecord(bytes,entry,entry.centralOffset);const raw=bytes.subarray(start,end);const value=entry.method===0?raw:inflateRawSync(raw,{maxOutputLength:entry.size+1});if(value.length!==entry.size||crc32(value)!==entry.crc)fail(`ZIP 校验和不匹配:${entry.name}。`);return value; } +export function planExtraction(input, options={}) { const archive=inspectZip(input,options);const policy=options.conflict||'rename';if(!['rename','skip','error'].includes(policy))fail('未知的同名冲突策略。');return {...archive,conflict:policy,writeOrder:archive.entries.map((e)=>({from:e.name,to:e.name,action:policy==='rename'?'rename-on-conflict':'write-or-'+policy}))}; } +function nodeIdentity(filePath, info) { return { path: filePath, dev: info.dev, ino: info.ino }; } +function sameNode(identity, info) { return identity.dev === info.dev && identity.ino === info.ino; } +async function recordCreatedDirectory(directory, createdDirectories) { + const info = await lstat(directory); + if (info.isSymbolicLink() || !info.isDirectory()) fail('新建目录的身份不安全。'); + createdDirectories.push(nodeIdentity(directory, info)); +} +async function rollbackCreatedFiles(createdFiles) { + for (const item of [...createdFiles].reverse()) { + try { + const info = await lstat(item.path); + if (!info.isSymbolicLink() && info.isFile() && sameNode(item, info)) await rm(item.path, { force: true }); + } catch {} + } +} +async function rollbackCreatedDirectories(createdDirectories) { + for (const item of [...createdDirectories].reverse()) { + try { + const info = await lstat(item.path); + if (!info.isSymbolicLink() && info.isDirectory() && sameNode(item, info)) await rmdir(item.path); + } catch {} + } +} +async function ensureSafeParents(root, target, assertActive = () => {}, createdDirectories = []) { + const rel = path.relative(root, target); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) fail('输出路径超出目标目录。'); + let current = root; + for (const part of rel.split(path.sep).slice(0, -1)) { + assertActive(); + current = path.join(current, part); + try { + const info = await lstat(current); + assertActive(); + if (info.isSymbolicLink() || !info.isDirectory()) fail('输出路径的上级目录不安全。'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + assertActive(); + await mkdir(current, { recursive: false, mode: 0o700 }); + await recordCreatedDirectory(current, createdDirectories); + assertActive(); + } + } +} +async function assertNoSymlinkAncestors(input) { const full=path.resolve(input), root=path.parse(full).root; let current=root; for(const part of path.relative(root,full).split(path.sep).filter(Boolean)){current=path.join(current,part);const info=await lstat(current);if(info.isSymbolicLink())fail('解压目标根目录不安全。');} } +export async function extractZipSafely(input, destination, options = {}) { + const assertActive = typeof options.assertActive === 'function' ? options.assertActive : () => {}; + assertActive(); + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + const plan = planExtraction(bytes, options); + const selected = path.resolve(destination); + const selectedInfo = await lstat(selected); + assertActive(); + if (selectedInfo.isSymbolicLink() || !selectedInfo.isDirectory()) fail('解压目标根目录不安全。'); + const root = await realpath(selected); + assertActive(); + await assertNoSymlinkAncestors(root); + assertActive(); + const rootInfo = await lstat(root); + assertActive(); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) fail('解压目标根目录不安全。'); + const createdFiles = []; + const createdDirectories = []; + try { + for (const entry of plan.entries) { + assertActive(); + await assertNoSymlinkAncestors(root); + assertActive(); + const parts = entry.name.split('/').filter(Boolean); + const target = path.resolve(root, ...parts); + await ensureSafeParents(root, target, assertActive, createdDirectories); + assertActive(); + if (entry.directory) { + try { + const info = await lstat(target); + assertActive(); + if (info.isSymbolicLink() || !info.isDirectory()) fail(`目录发生不安全冲突:${entry.name}`); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + assertActive(); + await mkdir(target, { mode: 0o700 }); + await recordCreatedDirectory(target, createdDirectories); + assertActive(); + } + continue; + } + + let final = target; + try { + await lstat(final); + assertActive(); + if (plan.conflict === 'skip') continue; + if (plan.conflict === 'error') fail(`目标位置存在同名条目:${entry.name}`); + let n = 1; + while (true) { + const candidate = `${target} (${n})`; + try { + await lstat(candidate); + assertActive(); + n += 1; + } catch (error) { + if (error.code === 'ENOENT') { + assertActive(); + final = candidate; + break; + } + throw error; + } + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + assertActive(); + } + + const temp = `${final}.archive-workbench-${randomBytes(12).toString('hex')}`; + let tempInfo; + try { + await assertNoSymlinkAncestors(root); + assertActive(); + await ensureSafeParents(root, final, assertActive, createdDirectories); + assertActive(); + await writeFile(temp, localData(bytes, entry), { mode: 0o600, flag: 'wx' }); + assertActive(); + tempInfo = await lstat(temp); + assertActive(); + await assertNoSymlinkAncestors(root); + assertActive(); + await ensureSafeParents(root, final, assertActive, createdDirectories); + assertActive(); + await link(temp, final); + createdFiles.push(nodeIdentity(final, tempInfo)); + assertActive(); + await rm(temp, { force: true }); + assertActive(); + await assertNoSymlinkAncestors(root); + assertActive(); + } catch (error) { + await rm(temp, { force: true }).catch(() => {}); + throw error; + } + } + assertActive(); + return plan; + } catch (error) { + await rollbackCreatedFiles(createdFiles); + await rollbackCreatedDirectories(createdDirectories); + throw error; + } +} +function crc32(bytes){let c=0xffffffff;for(const b of bytes){c^=b;for(let i=0;i<8;i+=1)c=(c>>>1)^((c&1)?0xedb88320:0)}return(c^0xffffffff)>>>0} +function put16(a,n){a.push(n&255,(n>>>8)&255)}function put32(a,n){a.push(n&255,(n>>>8)&255,(n>>>16)&255,(n>>>24)&255)} +export function createStoredZip(entries) { if(!Array.isArray(entries)||entries.length>LIMITS.entries||entries.length>0xffff)fail('ZIP 条目数量过多。');const parts=[],central=[];let offset=0,total=0;const seen=new Set();for(const item of entries){const name=normalizeEntryName(item.name),key=collisionKey(name);if(name.endsWith('/')||seen.has(key))fail(`存在大小写、Unicode 或目录冲突:${name}`);seen.add(key);const data=item.data instanceof Uint8Array?item.data:new TextEncoder().encode(String(item.data??''));if(data.length>LIMITS.single||data.length>0xffffffff||(total+=data.length)>LIMITS.total)fail('条目超过大小限制。');const nb=new TextEncoder().encode(name),crc=crc32(data),local=[];put32(local,0x04034b50);put16(local,20);put16(local,0x800);put16(local,0);put16(local,0);put16(local,0);put32(local,crc);put32(local,data.length);put32(local,data.length);put16(local,nb.length);put16(local,0);const header=Uint8Array.from(local);parts.push(header,nb,data);const c=[];put32(c,0x02014b50);put16(c,20);put16(c,20);put16(c,0x800);put16(c,0);put16(c,0);put16(c,0);put32(c,crc);put32(c,data.length);put32(c,data.length);put16(c,nb.length);put16(c,0);put16(c,0);put16(c,0);put16(c,0);put32(c,0);put32(c,offset);central.push(Uint8Array.from(c),nb);offset+=header.length+nb.length+data.length;if(offset>0xffffffff)fail('不支持输出 ZIP64。');}const cdOffset=offset,cdSize=central.reduce((n,x)=>n+x.length,0);if(cdSize>0xffffffff||cdOffset+cdSize>0xffffffff)fail('不支持输出 ZIP64。');const end=[];put32(end,0x06054b50);put16(end,0);put16(end,0);put16(end,entries.length);put16(end,entries.length);put32(end,cdSize);put32(end,cdOffset);put16(end,0);const all=[...parts,...central,Uint8Array.from(end)];const size=all.reduce((n,item)=>n+item.length,0);if(size>LIMITS.total+LIMITS.entries*512)fail('ZIP 输出超过安全限制。');const out=new Uint8Array(size);let at=0;for(const item of all){out.set(item,at);at+=item.length}return out;} +export { LIMITS }; diff --git a/plugins/archive-workbench/src/ui/app.mjs b/plugins/archive-workbench/src/ui/app.mjs new file mode 100644 index 00000000..a3982d29 --- /dev/null +++ b/plugins/archive-workbench/src/ui/app.mjs @@ -0,0 +1,10 @@ +const $=(id)=>document.getElementById(id);let plan,zipToken,destinationToken,filesToken;const bridge=globalThis.archiveWorkbench; +function render(next){plan=next;$('tree').replaceChildren();for(const entry of plan.entries){const li=document.createElement('li');li.textContent=`▣ ${entry.name} · ${entry.size.toLocaleString()} 字节`;$('tree').append(li)}$('summary').textContent=`共 ${plan.entries.length} 个条目,解压后 ${plan.total.toLocaleString()} 字节。遇到同名文件时默认重命名;预览未写入任何内容。`;} +function requireBridge(){if(!bridge)throw new Error('当前 ZTools 版本不支持压缩包能力桥。');} +$('chooseZip').addEventListener('click',async()=>{try{requireBridge();zipToken=await bridge.chooseZip();$('status').textContent='ZIP 已授权,请先预览再解压。'}catch(e){$('status').textContent=e.message}}); +$('inspect').addEventListener('click',async()=>{try{requireBridge();if(!zipToken)throw new Error('请先选择 ZIP。');render(await bridge.preview(zipToken));$('status').textContent='所有条目均已通过安全预检。'}catch(e){$('status').textContent=e.message;$('summary').textContent='压缩包已在写入前被拦截。'}}); +$('chooseDestination').addEventListener('click',async()=>{try{requireBridge();destinationToken=await bridge.chooseDestination();$('status').textContent='解压位置已授权,尚未写入任何文件。'}catch(e){$('status').textContent=e.message}}); +$('extract').addEventListener('click',async()=>{try{requireBridge();if(!zipToken||!destinationToken)throw new Error('请先选择 ZIP 和解压位置。');if(!confirm('确定按“同名时重命名”策略解压已通过预检的 ZIP 吗?现有文件不会被覆盖。'))return;render(await bridge.extract(zipToken,destinationToken));$('status').textContent='解压完成;同名文件已按策略重命名。'}catch(e){$('status').textContent=e.message}}); +$('chooseFiles').addEventListener('click',async()=>{try{requireBridge();filesToken=await bridge.chooseFiles();$('status').textContent='源文件已授权;只有在保存对话框确认后才会写入 ZIP。'}catch(e){$('status').textContent=e.message}}); +$('create').addEventListener('click',async()=>{try{requireBridge();if(!filesToken)throw new Error('请先选择要压缩的文件。');if(!confirm('确定用所选文件创建不压缩存储的 ZIP 吗?'))return;const result=await bridge.create(filesToken);$('status').textContent=`已安全创建包含 ${result.entries} 个条目的 ZIP。`}catch(e){$('status').textContent=e.message}}); +$('copy').addEventListener('click',async()=>{if(!plan)return;const text=JSON.stringify(plan,null,2);try{if(bridge?.copyText)await bridge.copyText(text);else await navigator.clipboard.writeText(text);$('status').textContent='解压预案已复制。'}catch{$('status').textContent='当前环境无法复制。'}}); diff --git a/plugins/archive-workbench/src/ui/index.html b/plugins/archive-workbench/src/ui/index.html new file mode 100644 index 00000000..c4c6cc85 --- /dev/null +++ b/plugins/archive-workbench/src/ui/index.html @@ -0,0 +1 @@ +压缩包管家受限压缩包工作台压缩包管家ZIP 预览已就绪选择 ZIP预览安全预案选择解压位置按重命名策略解压选择要压缩的文件创建 ZIP压缩包目录树尚未选择压缩包。 diff --git a/plugins/archive-workbench/src/ui/style.css b/plugins/archive-workbench/src/ui/style.css new file mode 100644 index 00000000..0395f468 --- /dev/null +++ b/plugins/archive-workbench/src/ui/style.css @@ -0,0 +1 @@ +:root{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#e8f1f7;color:#10213a}*{box-sizing:border-box}body{margin:0;background:linear-gradient(130deg,#e8f1f7,#bdd6ea)}main{max-width:1080px;margin:auto;padding:32px 20px}header,.intake{display:flex;gap:14px;align-items:center;justify-content:space-between;flex-wrap:wrap}header p{font:700 11px ui-monospace,monospace;letter-spacing:.17em;color:#3a78cb;margin:0}h1{font-size:clamp(30px,5vw,52px);margin:4px 0}.panel{background:#fff;border:1px solid #9cbed6;border-radius:14px;padding:18px;margin-top:18px;box-shadow:0 15px 35px #17375120;overflow-wrap:break-word}.panel p{line-height:1.6;text-wrap:pretty}.grid{display:grid;grid-template-columns:1.6fr 1fr;gap:18px}.grid .panel{margin-top:18px}.safety{border-top:6px solid #f29a4a}button{background:#3a78cb;color:#fff;border:1px solid #215995;border-radius:8px;padding:9px 12px;font:700 14px inherit;cursor:pointer;white-space:normal;overflow-wrap:break-word}button:focus-visible,input:focus-visible{outline:3px solid #f29a4a;outline-offset:3px}ul{list-style:none;padding:0;margin:0}li{font:14px ui-monospace,monospace;padding:8px 10px;margin:5px 0;background:#eef6fb;border-left:5px solid #3a78cb;word-break:break-all}@media(max-width:720px){main{padding:20px 12px}.grid{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/plugins/archive-workbench/test/archive.test.mjs b/plugins/archive-workbench/test/archive.test.mjs new file mode 100644 index 00000000..654694e9 --- /dev/null +++ b/plugins/archive-workbench/test/archive.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {deflateRawSync}from 'node:zlib';import {existsSync}from 'node:fs';import {mkdtemp,readFile,readdir,symlink,writeFile}from 'node:fs/promises';import {tmpdir}from 'node:os';import path from 'node:path';import {createStoredZip,extractZipSafely,inspectZip,normalizeEntryName,collisionKey,planExtraction}from '../src/core/archive.mjs'; +function put16(view,at,value){view.setUint16(at,value,true)}function put32(view,at,value){view.setUint32(at,value,true)} +function forgedDeflateZip(){const name=new TextEncoder().encode('bomb.txt'),raw=deflateRawSync(Buffer.alloc(1024*1024,65)),local=30+name.length,central=46+name.length,eocd=22,out=new Uint8Array(local+raw.length+central+eocd),v=new DataView(out.buffer);put32(v,0,0x04034b50);put16(v,4,20);put16(v,6,0x800);put16(v,8,8);put32(v,18,raw.length);put32(v,22,1);put16(v,26,name.length);out.set(name,30);out.set(raw,local);const c=local+raw.length;put32(v,c,0x02014b50);put16(v,c+4,20);put16(v,c+6,20);put16(v,c+8,0x800);put16(v,c+10,8);put32(v,c+20,raw.length);put32(v,c+24,1);put16(v,c+28,name.length);put32(v,c+42,0);out.set(name,c+46);const e=c+central;put32(v,e,0x06054b50);put16(v,e+8,1);put16(v,e+10,1);put32(v,e+12,central);put32(v,e+16,c);return out;} +test('path policy rejects traversal, Windows paths, reserved names, controls, ADS, and forbidden characters',()=>{for(const name of ['../x','/x','C:/x','\\\\server\\x','a\\b','a/../b','nul.txt','a/.','COM¹','com².txt','LPT³','folder/LPT².txt','safe.txt:hidden','folder/C:evil','line\nbreak.txt','tab\tname.txt','badname.txt','bad"name.txt','bad|name.txt','bad?name.txt','bad*name.txt'])assert.throws(()=>normalizeEntryName(name));assert.equal(normalizeEntryName('folder/file.txt'),'folder/file.txt');}); +for(const platform of ['win32','darwin','linux'])test(`archive entry path contract is Windows-safe on ${platform}`,()=>{for(const name of ['COM¹','LPT².txt','folder/COM³.log','safe.txt:hidden','folder/C:evil','folder/bad?.txt','folder/control\u0001.txt'])assert.throws(()=>normalizeEntryName(name));assert.equal(normalizeEntryName('中文 文件夹/正常¹ 文件.txt'),'中文 文件夹/正常¹ 文件.txt');}); +test('stored ZIP creates and round-trips a bounded plan',()=>{const bytes=createStoredZip([{name:'one.txt',data:'hello'},{name:'nested/two.txt',data:'world'}]);const plan=planExtraction(bytes);assert.equal(plan.entries.length,2);assert.equal(plan.total,10);}); +test('case and unicode collisions fail before extraction',()=>{assert.throws(()=>createStoredZip([{name:'A.txt',data:'x'},{name:'a.txt',data:'y'}]));assert.equal(collisionKey('Café.txt'),collisionKey('Café.txt'));}); +test('normalizes explicit directory entries without treating them as files',()=>{assert.equal(normalizeEntryName('folder/'),'folder/');assert.equal(collisionKey('folder/'),collisionKey('folder'));}); +test('archive limits reject compression bombs and unsupported links from central metadata',()=>{const bytes=createStoredZip([{name:'x.txt',data:'tiny'}]);assert.ok(inspectZip(bytes));assert.throws(()=>inspectZip(bytes,{limits:{total:1}}));}); +test('rejects symlink or device entries advertised by ZIP metadata',()=>{const bytes=createStoredZip([{name:'x.txt',data:'tiny'}]);const at=bytes.findIndex((_,i)=>bytes[i]===0x50&&bytes[i+1]===0x4b&&bytes[i+2]===1&&bytes[i+3]===2);new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength).setUint32(at+38,0o120000<<16,true);assert.throws(()=>inspectZip(bytes),/不安全的特殊条目/);}); +test('refuses a symlink output ancestor and writes normal files atomically',async()=>{const root=await mkdtemp(path.join(tmpdir(),'archive-'));const bytes=createStoredZip([{name:'safe/a.txt',data:'ok'}]);await extractZipSafely(bytes,root);assert.equal(await readFile(path.join(root,'safe','a.txt'),'utf8'),'ok');const blocked=await mkdtemp(path.join(tmpdir(),'archive-blocked-'));await symlink('/tmp',path.join(blocked,'safe'));await assert.rejects(extractZipSafely(bytes,blocked),/上级目录不安全/);}); +test('refuses a symlink destination root and never overwrites a collision race',async()=>{const target=await mkdtemp(path.join(tmpdir(),'archive-target-')),linkRoot=path.join(tmpdir(),`archive-link-${Date.now()}`),bytes=createStoredZip([{name:'a.txt',data:'new'}]);await symlink(target,linkRoot);await assert.rejects(extractZipSafely(bytes,linkRoot),/解压目标根目录不安全/);const root=await mkdtemp(path.join(tmpdir(),'archive-race-'));await writeFile(path.join(root,'a.txt'),'old');await extractZipSafely(bytes,root);assert.equal(await readFile(path.join(root,'a.txt'),'utf8'),'old');assert.equal(await readFile(path.join(root,'a.txt (1)'),'utf8'),'new');}); +test('bounds inflate before a forged central-directory size can allocate output',async()=>{const root=await mkdtemp(path.join(tmpdir(),'archive-bomb-'));await assert.rejects(extractZipSafely(forgedDeflateZip(),root));}); +test('creates a large stored entry without argument spreading and rejects ZIP64 metadata',()=>{const bytes=createStoredZip([{name:'large.bin',data:new Uint8Array(512*1024)}]);assert.ok(bytes.byteLength>512*1024);const eocd=bytes.length-22,v=new DataView(bytes.buffer);put16(v,eocd+4,1);assert.throws(()=>inspectZip(bytes),/多磁盘/);}); +test('rejects local header, checksum, and data-descriptor mismatches',async()=>{const base=createStoredZip([{name:'name.txt',data:'hello'}]),central=base.findIndex((_,i)=>base[i]===0x50&&base[i+1]===0x4b&&base[i+2]===1&&base[i+3]===2),root=await mkdtemp(path.join(tmpdir(),'archive-mismatch-'));const headerMismatch=base.slice();new DataView(headerMismatch.buffer).setUint32(central+16,1,true);await assert.rejects(extractZipSafely(headerMismatch,root),/本地文件头不匹配/);const nameMismatch=base.slice();nameMismatch[30]=120;await assert.rejects(extractZipSafely(nameMismatch,root),/本地文件名不匹配/);const crcMismatch=base.slice();crcMismatch[30+'name.txt'.length]=120;await assert.rejects(extractZipSafely(crcMismatch,root),/校验和不匹配/);const descriptor=base.slice();new DataView(descriptor.buffer).setUint16(central+8,0x808,true);assert.throws(()=>inspectZip(descriptor),/数据描述符/);}); +test('preflight rejects local name/header/data overlap and directory record mismatches',()=>{const base=createStoredZip([{name:'name.txt',data:'hello'}]),central=base.findIndex((_,i)=>base[i]===0x50&&base[i+1]===0x4b&&base[i+2]===1&&base[i+3]===2);const localName=base.slice();localName[30]=120;assert.throws(()=>planExtraction(localName),/本地文件名不匹配/);const localHeader=base.slice();new DataView(localHeader.buffer).setUint16(8,8,true);assert.throws(()=>planExtraction(localHeader),/本地文件头不匹配/);const large=createStoredZip([{name:'large.txt',data:new Uint8Array(100000)}]),largeCentral=large.findIndex((_,i)=>large[i]===0x50&&large[i+1]===0x4b&&large[i+2]===1&&large[i+3]===2);assert.ok(largeCentral>65000);new DataView(large.buffer).setUint16(28,65000,true);assert.throws(()=>planExtraction(large),/本地文件头不匹配/);const directory=createStoredZip([{name:'abcd',data:''}]),directoryCentral=directory.findIndex((_,i)=>directory[i]===0x50&&directory[i+1]===0x4b&&directory[i+2]===1&&directory[i+3]===2);directory.set(new TextEncoder().encode('dir/'),30);directory.set(new TextEncoder().encode('dir/'),directoryCentral+46);new DataView(directory.buffer).setUint32(directoryCentral+38,0o40000<<16,true);assert.doesNotThrow(()=>planExtraction(directory));new DataView(directory.buffer).setUint16(8,8,true);assert.throws(()=>planExtraction(directory),/本地文件头不匹配/);}); +test('session expiry rolls back a newly created explicit directory entry',async()=>{const bytes=createStoredZip([{name:'abcd',data:''}]),central=bytes.findIndex((_,i)=>bytes[i]===0x50&&bytes[i+1]===0x4b&&bytes[i+2]===1&&bytes[i+3]===2);bytes.set(new TextEncoder().encode('dir/'),30);bytes.set(new TextEncoder().encode('dir/'),central+46);new DataView(bytes.buffer).setUint32(central+38,0o40000<<16,true);const root=await mkdtemp(path.join(tmpdir(),'archive-directory-exit-')),created=path.join(root,'dir');const expired=Object.assign(new Error('expired'),{code:'SESSION_EXPIRED'});await assert.rejects(extractZipSafely(bytes,root,{assertActive(){if(existsSync(created))throw expired}}),{code:'SESSION_EXPIRED'});assert.deepEqual(await readdir(root),[]);}); +test('session expiry rolls back files created by earlier archive entries',async()=>{const bytes=createStoredZip([{name:'one.txt',data:'one'},{name:'two.txt',data:'two'}]),root=await mkdtemp(path.join(tmpdir(),'archive-transaction-exit-')),first=path.join(root,'one.txt');const expired=Object.assign(new Error('expired'),{code:'SESSION_EXPIRED'});await assert.rejects(extractZipSafely(bytes,root,{assertActive(){if(existsSync(first))throw expired}}),{code:'SESSION_EXPIRED'});assert.deepEqual(await readdir(root),[]);}); diff --git a/plugins/archive-workbench/test/dist-size.test.mjs b/plugins/archive-workbench/test/dist-size.test.mjs new file mode 100644 index 00000000..a9bfd687 --- /dev/null +++ b/plugins/archive-workbench/test/dist-size.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { + DIST_SIZE_LIMIT_BYTES, + assertWithinDistSizeLimit, + directoryBytes +} from '../scripts/dist-size.mjs'; + +function metadata(kind, size = 0) { + return { + size, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink' + }; +} + +function virtualDirectory(root, tree, entries) { + return directoryBytes(root, { + readEntries: async (directory) => (tree.get(directory) || []).map((name) => ({ name })), + inspectEntry: async (entryPath) => entries.get(entryPath), + baseDirectory: root + }); +} + +test('size gate allows exactly 14,500,000 bytes', () => { + assert.equal(assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES), 14_500_000); +}); + +test('size gate rejects 14,500,001 bytes without creating a large file', () => { + assert.throws(() => assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES + 1), /14\.5 MB safety limit/); +}); + +test('directory byte count includes nested regular files recursively', async () => { + const root = path.resolve('/virtual/archive-dist'); + const nested = path.join(root, 'nested'); + const tree = new Map([[root, ['root.js', 'nested']], [nested, ['child.css']]]); + const entries = new Map([ + [path.join(root, 'root.js'), metadata('file', 17)], + [nested, metadata('directory')], + [path.join(nested, 'child.css'), metadata('file', 23)] + ]); + assert.equal(await virtualDirectory(root, tree, entries), 40); +}); + +test('directory byte count rejects symbolic links and other special files', async () => { + for (const [name, kind, pattern] of [ + ['linked.js', 'symlink', /symbolic link/], + ['socket', 'special', /special file/] + ]) { + const root = path.resolve(`/virtual/archive-${kind}`); + const entryPath = path.join(root, name); + await assert.rejects( + virtualDirectory(root, new Map([[root, [name]]]), new Map([[entryPath, metadata(kind)]])), + pattern + ); + } +}); diff --git a/plugins/archive-workbench/test/manifest.test.mjs b/plugins/archive-workbench/test/manifest.test.mjs new file mode 100644 index 00000000..a507cf51 --- /dev/null +++ b/plugins/archive-workbench/test/manifest.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('root manifest exposes directly loadable source entrypoints', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + assert.equal(manifest.development, undefined); + assert.equal(manifest.main, 'src/ui/index.html'); + assert.equal(manifest.preload, 'preload/index.cjs'); + assert.equal(manifest.logo, 'logo.svg'); + for (const entry of [manifest.main, manifest.preload, manifest.logo]) await access(path.join(root, entry)); + assert.match(pathToFileURL(path.join(root, manifest.main)).href, /^file:/); +}); + +test('repository packaging invariants keep the built dist directory eligible', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + const buildScript = await readFile(path.join(root, 'scripts', 'build.mjs'), 'utf8'); + assert.equal(typeof packageJson.scripts?.build, 'string'); + assert.match(packageJson.scripts.build, /scripts\/build\.mjs/); + assert.equal(existsSync(path.join(root, path.dirname(manifest.main), 'plugin.json')), false); + for (const contract of ["delete m.development", "m.main='index.html'", "m.preload='preload/index.cjs'", "m.logo='logo.svg'"]) { + assert.ok(buildScript.includes(contract), `build script must preserve ${contract}`); + } +}); + +test('human-facing manifest and UI are localized to simplified Chinese', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + const html = await readFile(path.join(root, 'src', 'ui', 'index.html'), 'utf8'); + const renderer = await readFile(path.join(root, 'src', 'ui', 'app.mjs'), 'utf8'); + assert.equal(manifest.title, '压缩包管家'); + assert.match(manifest.description, /检查 ZIP 安全性/); + assert.match(manifest.features[0].explain, /安全的解压预案/); + assert.match(html, //); + assert.match(html, /压缩包管家<\/title>/); + for (const visibleEnglish of ['Archive Workbench', 'Choose ZIP', 'Preview safety plan', 'Write boundary', 'Copy extraction plan']) assert.equal(html.includes(visibleEnglish), false); + for (const visibleEnglish of ['Choose a ZIP first.', 'Plan copied', 'Copy unavailable']) assert.equal(renderer.includes(visibleEnglish), false); +}); diff --git a/plugins/archive-workbench/test/mcp.test.mjs b/plugins/archive-workbench/test/mcp.test.mjs new file mode 100644 index 00000000..c9001388 --- /dev/null +++ b/plugins/archive-workbench/test/mcp.test.mjs @@ -0,0 +1,578 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, rename, stat, truncate, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createStoredZip } from '../src/core/archive.mjs'; + +const require = createRequire(import.meta.url); +const preloadPath = fileURLToPath(new URL('../preload/index.cjs', import.meta.url)); +const preload = require(preloadPath); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + +async function zipFixture(entries) { + const directory = await mkdtemp(path.join(tmpdir(), 'archive-mcp-')); + const zipPath = path.join(directory, 'approved.zip'); + await writeFile(zipPath, createStoredZip(entries)); + return zipPath; +} + +function forgedEntryNameZip(safeName, unsafeName) { + const safeBytes = new TextEncoder().encode(safeName); + const unsafeBytes = new TextEncoder().encode(unsafeName); + assert.equal(unsafeBytes.length, safeBytes.length, 'forged ZIP names must have equal byte length'); + const bytes = createStoredZip([{ name: safeName, data: 'x' }]); + const central = bytes.findIndex((_, index) => bytes[index] === 0x50 && bytes[index + 1] === 0x4b && bytes[index + 2] === 1 && bytes[index + 3] === 2); + assert.ok(central > 0); + bytes.set(unsafeBytes, 30); + bytes.set(unsafeBytes, central + 46); + return bytes; +} + +function hostFor(zipPath, registerTool) { + const outCallbacks = []; + const ztools = { + async showOpenDialog() { return { filePaths: [zipPath] }; }, + onPluginOut(callback) { outCallbacks.push(callback); }, + ...(registerTool ? { registerTool } : {}) + }; + const target = { ztools }; + const bridge = preload.attachArchiveWorkbench(target); + return { target, bridge, outCallbacks }; +} + +function delayFirstFileCheck(filePath) { + const fsPromises = require('node:fs/promises'); + const canonicalPath = require('node:fs').realpathSync.native(filePath); + const originalLstat = fsPromises.lstat; + let releaseCheck; + let announceCheck; + let delayed = false; + const checkStarted = new Promise((resolve) => { announceCheck = resolve; }); + const checkGate = new Promise((resolve) => { releaseCheck = resolve; }); + fsPromises.lstat = async (...args) => { + if (!delayed && require('node:fs').realpathSync.native(args[0]) === canonicalPath) { + delayed = true; + announceCheck(); + await checkGate; + } + return originalLstat(...args); + }; + return { + checkStarted, + releaseCheck, + restore() { + releaseCheck(); + fsPromises.lstat = originalLstat; + } + }; +} + +test('manifest declarations and top-level native registrations stay one-to-one', () => { + const handlers = new Map(); + const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }; + preload.attachArchiveWorkbench(target); + assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort()); + assert.deepEqual(Object.keys(manifest.tools).sort(), ['inspect_approved_zip', 'plan_approved_zip']); + assert.ok([...handlers.values()].every((handler) => typeof handler === 'function')); + + const script = `const names=[];globalThis.ztools={registerTool(name,handler){if(typeof handler!=='function')throw Error('bad handler');names.push(name)}};require(${JSON.stringify(preloadPath)});process.stdout.write(JSON.stringify(names.sort()))`; + const child = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout), ['inspect_approved_zip', 'plan_approved_zip']); +}); + +test('one registration failure neither blocks the other tool nor breaks the human UI', () => { + const handlers = new Map(); + const { target } = hostFor('/not-opened.zip', (name, handler) => { + if (name === 'inspect_approved_zip') throw new Error('simulated host failure'); + handlers.set(name, handler); + }); + assert.deepEqual([...handlers.keys()], ['plan_approved_zip']); + assert.equal(typeof target.archiveWorkbench.chooseZip, 'function'); + assert.equal(typeof target.archiveWorkbench.extract, 'function'); +}); + +test('older hosts keep the complete human bridge without MCP support', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'human.txt', data: 'ok' }]); + const { bridge } = hostFor(zipPath); + const zipToken = await bridge.chooseZip(); + const preview = await bridge.preview(zipToken); + assert.equal(preview.entries[0].name, 'human.txt'); + assert.equal(typeof bridge.create, 'function'); + preload.__testClearGrants(); +}); + +test('human preview binds its token to the originally selected ZIP identity', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'human.txt', data: 'ok' }]); + const { bridge } = hostFor(zipPath); + const zipToken = await bridge.chooseZip(); + await rename(zipPath, `${zipPath}.selected`); + await writeFile(zipPath, createStoredZip([{ name: 'replacement.txt', data: 'no' }])); + await assert.rejects(bridge.preview(zipToken), /身份已发生变化|不安全/); + preload.__testClearGrants(); +}); + +test('real handlers inspect and plan only the latest human-approved ZIP with pagination', async () => { + preload.__testClearGrants(); + const firstPath = await zipFixture([{ name: 'old.txt', data: 'old' }]); + const secondPath = await zipFixture([ + { name: 'one.txt', data: '1' }, + { name: 'nested/two.txt', data: '22' }, + { name: 'three.txt', data: '333' } + ]); + let selectedPath = firstPath; + const handlers = new Map(); + const outCallbacks = []; + const target = { + ztools: { + async showOpenDialog() { return { filePaths: [selectedPath] }; }, + onPluginOut(callback) { outCallbacks.push(callback); }, + registerTool(name, handler) { handlers.set(name, handler); } + } + }; + const bridge = preload.attachArchiveWorkbench(target); + const oldToken = await bridge.chooseZip(); + selectedPath = secondPath; + const currentToken = await bridge.chooseZip(); + await assert.rejects(bridge.preview(oldToken), /授权无效或已过期/); + + const inspected = await handlers.get('inspect_approved_zip')({ offset: 1, limit: 1 }); + assert.equal(inspected.entries.length, 1); + assert.equal(inspected.entries[0].name, 'nested/two.txt'); + assert.equal(inspected.pagination.returned, 1); + assert.equal(inspected.pagination.hasMore, true); + assert.deepEqual(inspected.totals, { entries: 3, expandedBytes: 6, compressedBytes: 6 }); + assert.equal(inspected.conflict, null); + assert.equal(inspected.limits.pageLimit, 200); + + const planned = await handlers.get('plan_approved_zip')({ offset: 2, limit: 1, conflict: 'skip' }); + assert.deepEqual(planned.writePlan, [{ name: 'three.txt', action: 'write-or-skip' }]); + assert.equal(planned.conflict, 'skip'); + assert.equal(planned.totals.entries, 3); + + const serialized = JSON.stringify({ inspected, planned }); + assert.doesNotMatch(serialized, new RegExp(firstPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.doesNotMatch(serialized, new RegExp(secondPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.doesNotMatch(serialized, new RegExp(currentToken.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.deepEqual(Object.keys(manifest.tools).sort(), ['inspect_approved_zip', 'plan_approved_zip']); + outCallbacks.forEach((callback) => callback()); +}); + +test('tools require a live UI grant and onPluginOut revokes it', async () => { + preload.__testClearGrants(); + await assert.rejects(preload.inspectApprovedZipForMcp({}), { code: 'APPROVED_ZIP_REQUIRED' }); + const zipPath = await zipFixture([{ name: 'safe.txt', data: 'safe' }]); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + assert.equal((await handlers.get('inspect_approved_zip')({})).totals.entries, 1); + assert.equal(outCallbacks.length, 1); + outCallbacks[0](); + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); +}); + +test('delayed ZIP dialog result cannot restore approval after plugin exit', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'late.txt', data: 'late' }]); + let resolveDialog, onOut; + const handlers = new Map(); + const target = { ztools: { + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }), + onPluginOut(callback) { onOut = callback; }, + registerTool(name, handler) { handlers.set(name, handler); } + } }; + const bridge = preload.attachArchiveWorkbench(target); + const pending = bridge.chooseZip(); + onOut(); + resolveDialog({ filePaths: [zipPath] }); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + assert.equal(preload.__testActiveZipIdentity(), null); + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); +}); + +test('ZIP safety check completing after plugin exit cannot activate approval', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'late-check.txt', data: 'late' }]); + let onOut, releaseCheck, announceCheck; + const checkStarted = new Promise((resolve) => { announceCheck = resolve; }); + const checkGate = new Promise((resolve) => { releaseCheck = resolve; }); + const fsPromises = require('node:fs/promises'); + const originalLstat = fsPromises.lstat; + let delayed = false; + fsPromises.lstat = async (...args) => { + if (!delayed && args[0] === zipPath) { delayed = true; announceCheck(); await checkGate; } + return originalLstat(...args); + }; + const handlers = new Map(); + const target = { ztools: { + async showOpenDialog() { return { filePaths: [zipPath] }; }, + onPluginOut(callback) { onOut = callback; }, + registerTool(name, handler) { handlers.set(name, handler); } + } }; + const bridge = preload.attachArchiveWorkbench(target); + try { + const pending = bridge.chooseZip(); + await checkStarted; + onOut(); + releaseCheck(); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + assert.equal(preload.__testActiveZipIdentity(), null); + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); + } finally { + fsPromises.lstat = originalLstat; + } +}); + +test('destination and source-file selectors discard delayed results after plugin exit', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'archive-selector-exit-')); + const file = path.join(directory, 'source.txt'); + await writeFile(file, 'source'); + for (const [method, selectedPath] of [['chooseDestination', directory], ['chooseFiles', file]]) { + preload.__testClearGrants(); + let resolveDialog, onOut; + const target = { ztools: { + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }), + onPluginOut(callback) { onOut = callback; } + } }; + const bridge = preload.attachArchiveWorkbench(target); + const pending = bridge[method](); + onOut(); + resolveDialog({ filePaths: [selectedPath] }); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + } +}); + +test('human preview discards an authorized ZIP read that completes after plugin exit', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'late-preview.txt', data: 'late' }]); + const { bridge, outCallbacks } = hostFor(zipPath); + const zipToken = await bridge.chooseZip(); + const delayed = delayFirstFileCheck(zipPath); + try { + const pending = bridge.preview(zipToken); + await delayed.checkStarted; + outCallbacks[0](); + delayed.releaseCheck(); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + assert.equal(preload.__testActiveZipIdentity(), null); + } finally { + delayed.restore(); + } +}); + +test('extract and create do not write after their authorized input read outlives the session', async () => { + const zipPath = await zipFixture([{ name: 'late-extract.txt', data: 'late' }]); + const destination = await mkdtemp(path.join(tmpdir(), 'archive-extract-exit-')); + let openCall = 0; + let onExtractOut; + const extractTarget = { ztools: { + async showOpenDialog() { return { filePaths: [openCall++ === 0 ? zipPath : destination] }; }, + onPluginOut(callback) { onExtractOut = callback; } + } }; + preload.__testClearGrants(); + const extractBridge = preload.attachArchiveWorkbench(extractTarget); + const zipToken = await extractBridge.chooseZip(); + const destinationToken = await extractBridge.chooseDestination(); + const delayedZip = delayFirstFileCheck(zipPath); + try { + const pending = extractBridge.extract(zipToken, destinationToken); + await delayedZip.checkStarted; + onExtractOut(); + delayedZip.releaseCheck(); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + await assert.rejects(readFile(path.join(destination, 'late-extract.txt')), { code: 'ENOENT' }); + } finally { + delayedZip.restore(); + } + + const sourceDirectory = await mkdtemp(path.join(tmpdir(), 'archive-create-exit-')); + const source = path.join(sourceDirectory, 'source.txt'); + const output = path.join(sourceDirectory, 'should-not-exist.zip'); + await writeFile(source, 'source'); + let onCreateOut; + let saveCalls = 0; + const createTarget = { ztools: { + async showOpenDialog() { return { filePaths: [source] }; }, + async showSaveDialog() { saveCalls += 1; return { filePath: output }; }, + onPluginOut(callback) { onCreateOut = callback; } + } }; + preload.__testClearGrants(); + const createBridge = preload.attachArchiveWorkbench(createTarget); + const filesToken = await createBridge.chooseFiles(); + const delayedSource = delayFirstFileCheck(source); + try { + const pending = createBridge.create(filesToken); + await delayedSource.checkStarted; + onCreateOut(); + delayedSource.releaseCheck(); + await assert.rejects(pending, { code: 'SESSION_EXPIRED' }); + assert.equal(saveCalls, 0); + await assert.rejects(readFile(output), { code: 'ENOENT' }); + } finally { + delayedSource.restore(); + } +}); + +test('MCP cache-hit revalidation cannot return after the approved session exits', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'cached.txt', data: 'cached' }]); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + assert.equal((await handlers.get('inspect_approved_zip')({})).totals.entries, 1); + + const fsPromises = require('node:fs/promises'); + const originalLstat = fsPromises.lstat; + const canonicalZipPath = require('node:fs').realpathSync.native(zipPath); + let releaseCheck; + let announceCheck; + let delayed = false; + const checkStarted = new Promise((resolve) => { announceCheck = resolve; }); + const checkGate = new Promise((resolve) => { releaseCheck = resolve; }); + fsPromises.lstat = async (...args) => { + if (!delayed && require('node:fs').realpathSync.native(args[0]) === canonicalZipPath) { + delayed = true; + announceCheck(); + await checkGate; + } + return originalLstat(...args); + }; + try { + const pending = handlers.get('inspect_approved_zip')({}); + await checkStarted; + outCallbacks[0](); + releaseCheck(); + await assert.rejects(pending, { code: 'APPROVED_ZIP_REQUIRED' }); + } finally { + releaseCheck(); + fsPromises.lstat = originalLstat; + } +}); + +test('the active UI ZIP grant expires after five minutes', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'expires.txt', data: 'safe' }]); + const handlers = new Map(); + const { bridge } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + const originalNow = Date.now; + try { + Date.now = () => 0; + await bridge.chooseZip(); + } finally { + Date.now = originalNow; + } + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); +}); + +test('replacing an approved ZIP at the same path revokes the grant without leaking the path', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'approved.txt', data: 'approved' }]); + const handlers = new Map(); + const { bridge } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + const grantToken = await bridge.chooseZip(); + assert.equal((await handlers.get('inspect_approved_zip')({})).entries[0].name, 'approved.txt'); + await rename(zipPath, `${zipPath}.replaced`); + await writeFile(zipPath, createStoredZip([{ name: 'replacement.txt', data: 'different-size' }])); + await assert.rejects( + handlers.get('inspect_approved_zip')({}), + (error) => error.code === 'APPROVED_ZIP_UNAVAILABLE' + && !error.message.includes(zipPath) + && !error.message.includes(grantToken) + ); + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); +}); + +test('cache revalidation detects same-inode same-size rewrites even after mtime restoration', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'safe.txt', data: 'x' }]); + const fixedTime = new Date('2024-01-02T03:04:05.000Z'); + await utimes(zipPath, fixedTime, fixedTime); + const original = await stat(zipPath); + const handlers = new Map(); + const { bridge } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + const grantToken = await bridge.chooseZip(); + const approvedIdentity = preload.__testActiveZipIdentity(); + assert.equal(Object.isFrozen(approvedIdentity), true); + assert.equal(typeof approvedIdentity.ctimeMs, 'number'); + const first = await handlers.get('inspect_approved_zip')({}); + assert.equal(first.entries[0].name, 'safe.txt'); + const afterCache = preload.__testMcpMetrics(); + + const replacement = createStoredZip([{ name: 'evil.txt', data: 'x' }]); + assert.equal(replacement.length, original.size); + let changed; + for (let attempt = 0; attempt < 5; attempt += 1) { + await writeFile(zipPath, replacement); + await utimes(zipPath, fixedTime, fixedTime); + changed = await stat(zipPath); + if (changed.ctimeMs !== original.ctimeMs) break; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + assert.equal(changed.dev, original.dev); + assert.equal(changed.ino, original.ino); + assert.equal(changed.size, original.size); + assert.equal(changed.mtimeMs, original.mtimeMs); + assert.notEqual(changed.ctimeMs, original.ctimeMs); + + await assert.rejects( + handlers.get('inspect_approved_zip')({}), + (error) => error.code === 'APPROVED_ZIP_UNAVAILABLE' + && !error.message.includes(zipPath) + && !error.message.includes(grantToken) + ); + assert.deepEqual(preload.__testMcpMetrics(), afterCache); + await assert.rejects(handlers.get('inspect_approved_zip')({}), { code: 'APPROVED_ZIP_REQUIRED' }); +}); + +test('real MCP handler rejects a forged Win32 superscript device entry', async () => { + preload.__testClearGrants(); + const directory = await mkdtemp(path.join(tmpdir(), 'archive-mcp-device-')); + const zipPath = path.join(directory, 'device.zip'); + await writeFile(zipPath, forgedEntryNameZip('safe0.txt', 'LPT².txt')); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + await assert.rejects( + handlers.get('inspect_approved_zip')({}), + (error) => error.code === 'ZIP_INSPECTION_FAILED' && !error.message.includes(zipPath) + ); + outCallbacks.forEach((callback) => callback()); +}); + +test('concurrent inspect and plan share one bounded read and one safety inspection', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture(Array.from({ length: 80 }, (_, index) => ({ name: `entry-${index}.txt`, data: `value-${index}` }))); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + const before = preload.__testMcpMetrics(); + const [inspected, planned] = await Promise.all([ + handlers.get('inspect_approved_zip')({ offset: 0, limit: 20 }), + handlers.get('plan_approved_zip')({ offset: 20, limit: 20, conflict: 'error' }) + ]); + const after = preload.__testMcpMetrics(); + assert.equal(after.reads - before.reads, 1); + assert.equal(after.inspections - before.inspections, 1); + assert.equal(inspected.totals.entries, 80); + assert.equal(planned.totals.entries, 80); + assert.equal(planned.conflict, 'error'); + await handlers.get('inspect_approved_zip')({ offset: 40, limit: 20 }); + assert.deepEqual(preload.__testMcpMetrics(), after); + outCallbacks.forEach((callback) => callback()); +}); + +test('MCP rejects an approved ZIP above 64 MiB without reading or leaking its path', async () => { + preload.__testClearGrants(); + const directory = await mkdtemp(path.join(tmpdir(), 'archive-mcp-large-')); + const zipPath = path.join(directory, 'large-approved.zip'); + await writeFile(zipPath, createStoredZip([{ name: 'small.txt', data: 'x' }])); + await truncate(zipPath, preload.MCP_ZIP_SOURCE_MAX + 1); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + const before = preload.__testMcpMetrics(); + for (const name of ['inspect_approved_zip', 'plan_approved_zip']) { + await assert.rejects(handlers.get(name)({}), (error) => error.code === 'MCP_ZIP_TOO_LARGE' && !error.message.includes(zipPath)); + } + assert.deepEqual(preload.__testMcpMetrics(), before); + outCallbacks.forEach((callback) => callback()); +}); + +test('approved ZIP reads use the bounded handle path when the file grows after approval', async () => { + preload.__testClearGrants(); + const zipPath = await zipFixture([{ name: 'bounded.txt', data: 'ok' }]); + const approvedBytes = await readFile(zipPath); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + const fsPromises = require('node:fs/promises'); + const originalOpen = fsPromises.open; + let readFileCalled = false; + fsPromises.open = async (...args) => { + const real = await originalOpen(...args); + const expanded = Buffer.concat([approvedBytes, Buffer.from([0])]); + let cursor = 0; + return { + stat: (...statArgs) => real.stat(...statArgs), + async read(target, offset, length) { + const bytesRead = Math.min(length, expanded.length - cursor); + if (bytesRead > 0) expanded.copy(target, offset, cursor, cursor + bytesRead); + cursor += bytesRead; + return { bytesRead }; + }, + async readFile() { readFileCalled = true; return expanded; }, + close: () => real.close() + }; + }; + try { + await assert.rejects(handlers.get('inspect_approved_zip')({}), (error) => error.code === 'APPROVED_ZIP_UNAVAILABLE'); + assert.equal(readFileCalled, false); + } finally { + fsPromises.open = originalOpen; + outCallbacks.forEach((callback) => callback()); + } +}); + +test('strict runtime validation rejects paths, tokens, hostile prototypes and invalid pages', () => { + for (const value of [ + { path: '/tmp/a.zip' }, + { token: 'secret' }, + { grantId: 'secret' }, + { offset: -1 }, + { offset: preload.MCP_MAX_OFFSET + 1 }, + { offset: 1.5 }, + { limit: 0 }, + { limit: preload.MCP_MAX_LIMIT + 1 } + ]) assert.throws(() => preload.validateToolInput(value), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.validateToolInput({ conflict: 'overwrite' }, true), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.validateToolInput(JSON.parse('{"__proto__":{},"limit":1}')), { code: 'INVALID_TOOL_INPUT' }); + assert.throws(() => preload.validateToolInput(Object.assign(Object.create({ inherited: true }), { limit: 1 })), { code: 'INVALID_TOOL_INPUT' }); + const accessor = {}; + Object.defineProperty(accessor, 'limit', { enumerable: true, get() { throw new Error('must not execute'); } }); + assert.throws(() => preload.validateToolInput(accessor), { code: 'INVALID_TOOL_INPUT' }); + const symbol = { limit: 1 }; + symbol[Symbol('hidden')] = true; + assert.throws(() => preload.validateToolInput(symbol), { code: 'INVALID_TOOL_INPUT' }); + assert.deepEqual(preload.validateToolInput(Object.assign(Object.create(null), { offset: 2, limit: 3 })), { offset: 2, limit: 3 }); + const previousLimit = Object.getOwnPropertyDescriptor(Object.prototype, 'limit'); + const previousConflict = Object.getOwnPropertyDescriptor(Object.prototype, 'conflict'); + Object.defineProperties(Object.prototype, { + limit: { value: 999, configurable: true }, + conflict: { value: 'overwrite', configurable: true } + }); + try { + assert.deepEqual(preload.validateToolInput({}, true), { offset: 0, limit: 100, conflict: 'rename' }); + } finally { + if (previousLimit) Object.defineProperty(Object.prototype, 'limit', previousLimit); + else delete Object.prototype.limit; + if (previousConflict) Object.defineProperty(Object.prototype, 'conflict', previousConflict); + else delete Object.prototype.conflict; + } +}); + +test('maximum pages remain bounded and contain relative names only', async () => { + preload.__testClearGrants(); + const entries = Array.from({ length: 240 }, (_, index) => ({ name: `folder_${String(index).padStart(3, '0')}/${'n'.repeat(180)}_${index}.txt`, data: 'x' })); + const zipPath = await zipFixture(entries); + const handlers = new Map(); + const { bridge, outCallbacks } = hostFor(zipPath, (name, handler) => handlers.set(name, handler)); + await bridge.chooseZip(); + const inspected = await handlers.get('inspect_approved_zip')({ offset: 0, limit: 200 }); + const planned = await handlers.get('plan_approved_zip')({ offset: 0, limit: 200, conflict: 'rename' }); + assert.ok(inspected.entries.length <= 200); + assert.ok(planned.writePlan.length <= 200); + assert.equal(inspected.totals.entries, 240); + assert.equal(planned.totals.entries, 240); + assert.ok(Buffer.byteLength(JSON.stringify(inspected), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.ok(Buffer.byteLength(JSON.stringify(planned), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.ok(inspected.entries.every((entry) => !path.isAbsolute(entry.name))); + assert.ok(planned.writePlan.every((entry) => !path.isAbsolute(entry.name))); + outCallbacks.forEach((callback) => callback()); +});
受限压缩包工作台