Open
feat: 支持 Remote-WSL/SSH 远端日志解析 + 纯 JS zip 解压(yauzl),版本升级至 0.0.3 #23
liruichenAdd on Aug 10
feat: 支持 Remote-WSL/SSH 远端日志解析 + 纯 JS zip 解压(yauzl),版本升级至 0.0.3 #23
Open
Total: 20 files changed+780-278
| @@ -0,0 +1,20 @@ | |||
| 1 | +* text=auto eol=lf | ||
| 2 | + | ||
| 3 | +# Windows command scripts still need CRLF when invoked by cmd.exe. | ||
| 4 | +*.bat text eol=crlf | ||
| 5 | +*.cmd text eol=crlf | ||
| 6 | + | ||
| 7 | +# Dump fixtures are byte-sensitive golden-test inputs, including extensionless files. | ||
| 8 | +tests/mock_dump/** text eol=lf | ||
| 9 | +tests/test_stubs/** text eol=lf | ||
| 10 | + | ||
| 11 | +# Never apply text conversion to archive or image fixtures. | ||
| 12 | +*.gz binary | ||
| 13 | +*.tgz binary | ||
| 14 | +*.tar binary | ||
| 15 | +*.zip binary | ||
| 16 | +*.png binary | ||
| 17 | +*.jpg binary | ||
| 18 | +*.jpeg binary | ||
| 19 | +*.gif binary | ||
| 20 | +*.webp binary | ||
| @@ -23,6 +23,8 @@ tests/** | |||
| 23 | 23 | ||
| 24 | # Artifacts | 24 | # Artifacts |
| 25 | node_modules/** | 25 | node_modules/** |
| 26 | +!node_modules/yauzl/** | ||
| 27 | +!node_modules/pend/** | ||
| 26 | **/*.vsix | 28 | **/*.vsix |
| 27 | .DS_Store | 29 | .DS_Store |
| 28 | dump_info/** | 30 | dump_info/** |
| @@ -1,21 +1,32 @@ | |||
| 1 | # openUBMC 一键日志解析 | 1 | # openUBMC 一键日志解析 |
| 2 | 2 | ||
| 3 | -openUBMC Studio 扩展,把 openUBMC 服务器导出的"一键日志 dump"还原成 openUBMC Web UI 风格的交互式可视化页面。原本散落在一堆文本、CSV、MDB dump 和 `.gz` 里的硬件库存、告警、传感器、功率、调速、性能、线缆、日志等信息,在这里变成现成的视图、图表和跨文件关联,并带智能诊断。纯 Node.js 实现,运行时零依赖。 | 3 | +openUBMC Studio 扩展,把服务器导出的“一键日志 dump”还原成 Web UI 风格的交互式可视化页面。它可以解析硬件库存、告警、传感器、功率、调速、性能、线缆、系统日志等信息,并提供跨文件关联和智能诊断。 |
| 4 | 4 | ||
| 5 | -## 概览 | 5 | +## 特性 |
| 6 | 6 | ||
| 7 | -- **一键还原现场**:选中一个包含 `AppDump` 的 dump 目录,扩展直接在 Studio 里打开可视化面板,无需起服务、无需浏览器 | 7 | +- **一键还原现场**:打开包含 `AppDump` 的目录,或直接打开 `.tar.gz/.tgz/.tar/.zip` 日志包。 |
| 8 | -- **覆盖全栈信息**:首页概览、FRU/板卡版本、告警与传感器、操作日志、功率与进风温度、性能(CPU/内存/Top)、调速、CSR、线缆检测、丝印、同步属性 Trace、系统日志、故障诊断 | 8 | +- **完整可视化**:首页概览、FRU/板卡版本、告警与传感器、操作日志、功率与温度、性能、调速、CSR、线缆检测、丝印、同步属性 Trace、系统日志和故障诊断。 |
| 9 | -- **智能诊断**:基于告警/操作失败/性能规则对现场做自动检测,给出可读的诊断报告 | 9 | +- **本地与远程一致**:扩展作为 Workspace Extension 运行;本地窗口在 Windows/macOS/Linux 读取本地日志,Remote-WSL/SSH 窗口在远端扩展宿主读取远端日志。 |
| 10 | -- **跨平台**:作为 Studio 扩展随 Studio 在 macOS / Windows / Linux 运行 | 10 | +- **无外部解压工具依赖**:tar、gzip 和 zip 均由随扩展打包的纯 JavaScript 实现处理,不要求系统安装 `tar` 或 `unzip`。 |
| 11 | +- **面板隔离**:每个面板持有独立的 `DumpContext`,可以同时查看多个 dump。 | ||
| 11 | 12 | ||
| 12 | ## 架构 | 13 | ## 架构 |
| 13 | 14 | ||
| 14 | -- 扩展入口 `src/extension.ts`:注册命令、构建 webview HTML、用 `postMessage` 桥接前后端 | 15 | +```text |
| 15 | -- `src/api-bridge.ts`:把 webview 的 `/api/*` 请求路由到 `api/*.js` 处理器(`(req, res)` 签名),无需起 HTTP 服务 | 16 | +Studio 命令 / 资源管理器 URI |
| 16 | -- `api/*.js`:从 dump 目录读取并解析各子系统数据;前端 `js/` + `css.js` 渲染页面 | 17 | + ↓ |
| 18 | +Workspace Extension Host(本地或 WSL/SSH) | ||
| 19 | + ↓ | ||
| 20 | +src/backend typed handlers + parsers | ||
| 21 | + ↓ postMessage bridge | ||
| 22 | +Vue 3 + TypeScript Webview | ||
| 23 | +``` | ||
| 17 | 24 | ||
| 18 | -Studio 本身已跨平台,扩展即覆盖 macOS / Windows / Linux。 | 25 | +- `src/extension.ts`:命令、远端 URI、最近记录与归档缓存生命周期。 |
| 26 | +- `src/host/`:创建 Vue Webview,并把请求分发给当前面板的 `DumpContext`。 | ||
| 27 | +- `src/backend/`:纯 TypeScript handler、解析器和机型 Profile。 | ||
| 28 | +- `src/shared/`:前后端共享的路由和响应类型。 | ||
| 29 | +- `ui/`:Vue 3、Pinia、Vite 和打包后的 ECharts SVG 渲染器。 | ||
| 19 | 30 | ||
| 20 | ## 安装与构建 | 31 | ## 安装与构建 |
| 21 | 32 | ||
| @@ -23,43 +34,46 @@ Studio 本身已跨平台,扩展即覆盖 macOS / Windows / Linux。 | |||
| 23 | 34 | ||
| 24 | ```bash | 35 | ```bash |
| 25 | npm install | 36 | npm install |
| 26 | -npm run compile # 编译 TypeScript 到 out/,并把前端资源装配到 webview/ | 37 | +npm run build # TypeScript + Vue Webview |
| 27 | -npm run package # 打包 VSIX | 38 | +npm test # 后端与 Vue 测试 |
| 28 | -npm run deploy # 打包并安装到本地 Studio,重启 Studio 窗口生效 | 39 | +npm run golden # 40 条 API route 的冻结输出门禁 |
| 40 | +npm run lint # ESLint + Prettier | ||
| 41 | +npm run package # 生成 VSIX | ||
| 42 | +npm run deploy # 打包并安装到本地 Studio | ||
| 29 | ``` | 43 | ``` |
| 30 | 44 | ||
| 31 | -手动安装某个 VSIX: | 45 | +`npm run deploy` 会从 `BMCSTUDIO_CLI`、系统 PATH 和常见安装目录查找 Studio CLI。也可以在扩展面板中选择 “Install from VSIX...” 手动安装。 |
| 32 | 46 | ||
| 33 | -```bash | 47 | +### Remote-WSL / SSH |
| 34 | -"/Applications/openUBMC Studio.app/Contents/Resources/app/bin/studio" \ | 48 | + |
| 35 | - --install-extension bmcstudio-log-reviewer-<version>.vsix | 49 | +日志插件必须安装在当前远端环境中。在已连接 WSL/SSH 的窗口打开扩展面板,确认扩展显示为“安装在 WSL/SSH”;必要时在该窗口执行 “Install from VSIX...”。可以通过 “Developer: Show Running Extensions” 确认 `openubmc.bmcstudio-log-reviewer` 运行在 Workspace/远端宿主。 |
| 36 | -``` | 50 | + |
| 51 | +远端日志 URI 会保存在“最近打开”中。压缩包解压到远端扩展的 `globalStorageUri` 缓存;缓存过期后,只要原压缩包仍存在,点击最近记录会自动重新解压。 | ||
| 37 | 52 | ||
| 38 | ## 使用 | 53 | ## 使用 |
| 39 | 54 | ||
| 40 | -点左侧活动栏的「一键日志解析」图标打开侧边面板,里面有两个入口,也会列出最近打开过的 dump: | 55 | +点左侧活动栏的「一键日志解析」图标: |
| 41 | 56 | ||
| 42 | -- **打开压缩包**:选一个 `.tar.gz / .tgz / .tar / .zip` 日志包,自动解压并定位 `AppDump` 后打开(拿到原始日志包直接用这个) | 57 | +- **打开压缩包**:选择 `.tar.gz/.tgz/.tar/.zip`,自动解压、定位 `AppDump` 并打开。 |
| 43 | -- **打开 dump 目录**:选一个已解压、含 `AppDump` 的文件夹 | 58 | +- **打开 dump 目录**:选择已经解压且包含 `AppDump` 的目录。 |
| 59 | +- **最近打开**:重新打开目录或归档;归档缓存丢失时会从原压缩包恢复。 | ||
| 44 | 60 | ||
| 45 | -也可以在资源管理器里**右键**日志压缩包或 dump 文件夹,选对应命令;或用命令面板。打开后顶部标签页切换不同维度。 | 61 | +也可以在资源管理器里右键日志压缩包或 dump 文件夹,或从命令面板执行对应命令。`tests/mock_dump/` 是可用于快速验证的完整样例。 |
| 46 | - | ||
| 47 | -`tests/mock_dump/` 是一份完整的样例 dump,可用于快速验证。 | ||
| 48 | 62 | ||
| 49 | ## 项目结构 | 63 | ## 项目结构 |
| 50 | 64 | ||
| 51 | -``` | 65 | +```text |
| 52 | -src/ # 扩展源码(extension.ts + api-bridge.ts) | 66 | +src/ # 扩展入口、host、typed backend、共享契约 |
| 53 | -api/ # 17 个后端数据模块 | 67 | +ui/ # Vue 3 Webview |
| 54 | -js/ css.js # 前端页面与样式 | 68 | +test/backend/ # handler、解析器、golden 与安全测试 |
| 55 | -utils.js # 路径解析 + MDB/CSV 解析器 | 69 | +test/webview/ # Vue 组件和页面测试 |
| 56 | -scripts/build-webview.js # 把前端资源装配进 webview/(打包用) | 70 | +test/golden/ # 冻结的 API 输出 |
| 57 | -tests/ # mock_dump 与线缆检测场景夹具 | 71 | +tests/mock_dump/ # 完整样例 dump |
| 58 | -docs/ # 设计文档与代码导读(GUIDE.md) | 72 | +docs/ # 设计文档与代码导读 |
| 59 | ``` | 73 | ``` |
| 60 | 74 | ||
| 61 | -代码逐文件导读见 [docs/GUIDE.md](https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main/docs/GUIDE.md),工程化与协作约定见 [AGENTS.md](https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main/AGENTS.md)。 | 75 | +工程化与协作约定见 [AGENTS.md](https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main/AGENTS.md)。 |
| 62 | 76 | ||
| 63 | ## 许可证 | 77 | ## 许可证 |
| 64 | 78 | ||
| 65 | -[木兰宽松许可证, 第2版 (MulanPSL-2.0)](https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main/LICENSE) | 79 | +[木兰宽松许可证,第 2 版(MulanPSL-2.0)](https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main/LICENSE) |
| @@ -12,7 +12,8 @@ | |||
| 12 | "echarts": "^6.1.0", | 12 | "echarts": "^6.1.0", |
| 13 | "pinia": "^3.0.4", | 13 | "pinia": "^3.0.4", |
| 14 | "vue": "^3.5.38", | 14 | "vue": "^3.5.38", |
| 15 | - "vue-router": "^4.6.4" | 15 | + "vue-router": "^4.6.4", |
| 16 | + "yauzl": "^3.4.0" | ||
| 16 | }, | 17 | }, |
| 17 | "devDependencies": { | 18 | "devDependencies": { |
| 18 | "@eslint/js": "^9.39.4", | 19 | "@eslint/js": "^9.39.4", |
| @@ -32,7 +33,8 @@ | |||
| 32 | "typescript-eslint": "^8.62.0", | 33 | "typescript-eslint": "^8.62.0", |
| 33 | "vite": "^5.4.21", | 34 | "vite": "^5.4.21", |
| 34 | "vitest": "^2.1.9", | 35 | "vitest": "^2.1.9", |
| 35 | - "vue-tsc": "^2.2.12" | 36 | + "vue-tsc": "^2.2.12", |
| 37 | + "yazl": "^2.5.1" | ||
| 36 | }, | 38 | }, |
| 37 | "engines": { | 39 | "engines": { |
| 38 | "node": ">=18.0.0", | 40 | "node": ">=18.0.0", |
| @@ -6089,7 +6091,6 @@ | |||
| 6089 | "version": "1.2.0", | 6091 | "version": "1.2.0", |
| 6090 | "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", | 6092 | "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", |
| 6091 | "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", | 6093 | "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", |
| 6092 | - "dev": true, | ||
| 6093 | "license": "MIT" | 6094 | "license": "MIT" |
| 6094 | }, | 6095 | }, |
| 6095 | "node_modules/perfect-debounce": { | 6096 | "node_modules/perfect-debounce": { |
| @@ -8176,7 +8177,6 @@ | |||
| 8176 | "version": "3.4.0", | 8177 | "version": "3.4.0", |
| 8177 | "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-3.4.0.tgz", | 8178 | "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-3.4.0.tgz", |
| 8178 | "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", | 8179 | "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", |
| 8179 | - "dev": true, | ||
| 8180 | "license": "MIT", | 8180 | "license": "MIT", |
| 8181 | "dependencies": { | 8181 | "dependencies": { |
| 8182 | "pend": "~1.2.0" | 8182 | "pend": "~1.2.0" |
| @@ -2,7 +2,7 @@ | |||
| 2 | "name": "bmcstudio-log-reviewer", | 2 | "name": "bmcstudio-log-reviewer", |
| 3 | "displayName": "openUBMC 一键日志解析", | 3 | "displayName": "openUBMC 一键日志解析", |
| 4 | "description": "将 openUBMC 一键日志 dump 还原成 Web UI 风格的可视化页面,支持跨文件关联、数据图表化与智能诊断", | 4 | "description": "将 openUBMC 一键日志 dump 还原成 Web UI 风格的可视化页面,支持跨文件关联、数据图表化与智能诊断", |
| 5 | - "version": "0.0.1", | 5 | + "version": "0.0.3", |
| 6 | "publisher": "openubmc", | 6 | "publisher": "openubmc", |
| 7 | "license": "MulanPSL-2.0", | 7 | "license": "MulanPSL-2.0", |
| 8 | "repository": "https://gitcode.com/openUBMC/bmcstudio_log_reviewer", | 8 | "repository": "https://gitcode.com/openUBMC/bmcstudio_log_reviewer", |
| @@ -24,8 +24,14 @@ | |||
| 24 | ], | 24 | ], |
| 25 | "main": "./out/extension.js", | 25 | "main": "./out/extension.js", |
| 26 | "extensionKind": [ | 26 | "extensionKind": [ |
| 27 | - "ui" | 27 | + "workspace" |
| 28 | ], | 28 | ], |
| 29 | + "capabilities": { | ||
| 30 | + "virtualWorkspaces": { | ||
| 31 | + "supported": false, | ||
| 32 | + "description": "日志解析需要扩展宿主直接访问 dump 文件和本机临时存储。" | ||
| 33 | + } | ||
| 34 | + }, | ||
| 29 | "activationEvents": [ | 35 | "activationEvents": [ |
| 30 | "onView:logReviewer.recent", | 36 | "onView:logReviewer.recent", |
| 31 | "onCommand:newlogtool.openDump", | 37 | "onCommand:newlogtool.openDump", |
| @@ -170,13 +176,14 @@ | |||
| 170 | "golden": "vitest run test/backend/dispatch-golden.test.ts", | 176 | "golden": "vitest run test/backend/dispatch-golden.test.ts", |
| 171 | "lint": "eslint . && prettier --check .", | 177 | "lint": "eslint . && prettier --check .", |
| 172 | "package": "npm run build && vsce package --baseContentUrl https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main --baseImagesUrl https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main", | 178 | "package": "npm run build && vsce package --baseContentUrl https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main --baseImagesUrl https://raw.gitcode.com/openUBMC/bmcstudio_log_reviewer/raw/main", |
| 173 | - "deploy": "npm run package && \"/Applications/openUBMC Studio.app/Contents/Resources/app/bin/studio\" --install-extension bmcstudio-log-reviewer-$npm_package_version.vsix && echo 'Deployed OK,重启 Studio 窗口生效'" | 179 | + "deploy": "npm run package && node scripts/deploy.mjs" |
| 174 | }, | 180 | }, |
| 175 | "dependencies": { | 181 | "dependencies": { |
| 176 | "echarts": "^6.1.0", | 182 | "echarts": "^6.1.0", |
| 177 | "pinia": "^3.0.4", | 183 | "pinia": "^3.0.4", |
| 178 | "vue": "^3.5.38", | 184 | "vue": "^3.5.38", |
| 179 | - "vue-router": "^4.6.4" | 185 | + "vue-router": "^4.6.4", |
| 186 | + "yauzl": "^3.4.0" | ||
| 180 | }, | 187 | }, |
| 181 | "devDependencies": { | 188 | "devDependencies": { |
| 182 | "@eslint/js": "^9.39.4", | 189 | "@eslint/js": "^9.39.4", |
| @@ -196,6 +203,7 @@ | |||
| 196 | "typescript-eslint": "^8.62.0", | 203 | "typescript-eslint": "^8.62.0", |
| 197 | "vite": "^5.4.21", | 204 | "vite": "^5.4.21", |
| 198 | "vitest": "^2.1.9", | 205 | "vitest": "^2.1.9", |
| 199 | - "vue-tsc": "^2.2.12" | 206 | + "vue-tsc": "^2.2.12", |
| 207 | + "yazl": "^2.5.1" | ||
| 200 | } | 208 | } |
| 201 | } | 209 | } |
| @@ -0,0 +1,53 @@ | |||
| 1 | +import fs from 'node:fs'; | ||
| 2 | +import path from 'node:path'; | ||
| 3 | +import { spawnSync } from 'node:child_process'; | ||
| 4 | +import { fileURLToPath } from 'node:url'; | ||
| 5 | + | ||
| 6 | +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); | ||
| 7 | +const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8')); | ||
| 8 | +const vsixPath = path.join(projectRoot, `${manifest.name}-${manifest.version}.vsix`); | ||
| 9 | + | ||
| 10 | +if (!fs.existsSync(vsixPath)) { | ||
| 11 | + console.error(`VSIX 不存在: ${vsixPath}`); | ||
| 12 | + process.exit(1); | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +const candidates = [process.env.BMCSTUDIO_CLI].filter(Boolean); | ||
| 16 | +if (process.platform === 'darwin') { | ||
| 17 | + candidates.push('/Applications/openUBMC Studio.app/Contents/Resources/app/bin/studio', 'studio'); | ||
| 18 | +} else if (process.platform === 'win32') { | ||
| 19 | + if (process.env.LOCALAPPDATA) { | ||
| 20 | + candidates.push( | ||
| 21 | + path.join(process.env.LOCALAPPDATA, 'Programs', 'openUBMC Studio', 'bin', 'studio.cmd'), | ||
| 22 | + ); | ||
| 23 | + } | ||
| 24 | + if (process.env.ProgramFiles) { | ||
| 25 | + candidates.push(path.join(process.env.ProgramFiles, 'openUBMC Studio', 'bin', 'studio.cmd')); | ||
| 26 | + } | ||
| 27 | + candidates.push('studio'); | ||
| 28 | +} else { | ||
| 29 | + candidates.push('/usr/local/bin/studio', '/usr/bin/studio', 'studio'); | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +for (const cli of candidates) { | ||
| 33 | + const isPath = path.isAbsolute(cli); | ||
| 34 | + if (isPath && !fs.existsSync(cli)) continue; | ||
| 35 | + | ||
| 36 | + const result = spawnSync(cli, ['--install-extension', vsixPath], { | ||
| 37 | + cwd: projectRoot, | ||
| 38 | + stdio: 'inherit', | ||
| 39 | + shell: process.platform === 'win32', | ||
| 40 | + }); | ||
| 41 | + if (result.error && result.error.code === 'ENOENT') continue; | ||
| 42 | + if (result.status === 0) { | ||
| 43 | + console.log('Deployed OK,重启 Studio 窗口生效'); | ||
| 44 | + process.exit(0); | ||
| 45 | + } | ||
| 46 | + console.error(`安装失败: ${cli}(退出码 ${String(result.status)})`); | ||
| 47 | + process.exit(result.status || 1); | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +console.error( | ||
| 51 | + '未找到 openUBMC Studio CLI。请把 studio 加入 PATH,或设置 BMCSTUDIO_CLI 为 CLI 的完整路径。', | ||
| 52 | +); | ||
| 53 | +process.exit(1); | ||
| @@ -1,11 +1,15 @@ | |||
| 1 | import * as fs from 'fs'; | 1 | import * as fs from 'fs'; |
| 2 | import * as os from 'os'; | 2 | import * as os from 'os'; |
| 3 | import * as path from 'path'; | 3 | import * as path from 'path'; |
| 4 | -import { execFile } from 'child_process'; | 4 | +import type { Readable } from 'stream'; |
| 5 | -import { promisify } from 'util'; | 5 | +import { pipeline } from 'stream/promises'; |
| 6 | -import { extractTarTo, gunzipToFile, DEFAULT_MAX_TOTAL_BYTES } from './backend/parsers/tar'; | 6 | +import * as yauzl from 'yauzl'; |
| 7 | - | 7 | +import { |
| 8 | -const execFileAsync = promisify(execFile); | 8 | + extractTarTo, |
| 9 | + gunzipToFile, | ||
| 10 | + DEFAULT_MAX_ENTRIES, | ||
| 11 | + DEFAULT_MAX_TOTAL_BYTES, | ||
| 12 | +} from './backend/parsers/tar'; | ||
| 9 | 13 | ||
| 10 | /** Supported one-key log archive extensions. */ | 14 | /** Supported one-key log archive extensions. */ |
| 11 | export const ARCHIVE_EXTS = ['.tar.gz', '.tgz', '.tar', '.zip']; | 15 | export const ARCHIVE_EXTS = ['.tar.gz', '.tgz', '.tar', '.zip']; |
| @@ -43,80 +47,136 @@ export function findDumpRoot(extractDir: string): string | null { | |||
| 43 | return walk(extractDir, 0); | 47 | return walk(extractDir, 0); |
| 44 | } | 48 | } |
| 45 | 49 | ||
| 46 | -const EXEC_OPTS = { maxBuffer: 64 * 1024 * 1024, timeout: 300000 } as const; | 50 | +function safeZipJoin(destination: string, entryName: string): string { |
| 47 | - | 51 | + if ( |
| 48 | -/** Reject a path-traversal / absolute entry name (zip-slip guard for the .zip path). */ | 52 | + !entryName || |
| 49 | -function assertSafeEntryNames(names: string[]): void { | 53 | + entryName.includes('\0') || |
| 50 | - for (const raw of names) { | 54 | + /^[a-zA-Z]:[\\/]/.test(entryName) || |
| 51 | - const name = raw.trim(); | 55 | + /^[\\/]/.test(entryName) || |
| 52 | - if (!name) continue; | 56 | + entryName.split(/[\\/]/).includes('..') |
| 53 | - if (path.isAbsolute(name) || name.split(/[\\/]/).includes('..')) { | 57 | + ) { |
| 54 | - throw new Error(`检测到不安全的归档条目(路径逃逸): ${name}`); | 58 | + throw new Error(`检测到不安全的归档条目(路径逃逸): ${entryName}`); |
| 55 | - } | ||
| 56 | } | 59 | } |
| 60 | + | ||
| 61 | + const base = path.resolve(destination); | ||
| 62 | + const target = path.resolve(base, ...entryName.split(/[\\/]/)); | ||
| 63 | + if (target !== base && !target.startsWith(base + path.sep)) { | ||
| 64 | + throw new Error(`检测到不安全的归档条目(路径逃逸): ${entryName}`); | ||
| 65 | + } | ||
| 66 | + return target; | ||
| 57 | } | 67 | } |
| 58 | 68 | ||
| 59 | /** | 69 | /** |
| 60 | - * Extract a `.zip`. Prefers the system `tar` (libarchive/bsdtar, present on | 70 | + * Extract a `.zip` with a pure-JS, lazy reader. This intentionally avoids the |
| 61 | - * macOS / Windows 10 1803+ and some Linux); falls back to `unzip` (Info-ZIP, | 71 | + * host's `tar`/`unzip` commands: minimal WSL images often have GNU tar (which |
| 62 | - * the usual Linux case where GNU tar can't read zip). Both run via `execFile` | 72 | + * cannot read zip) but no `unzip`. Entry count/expanded bytes are bounded and |
| 63 | - * (argument array, no shell) so paths/entry names can never inject a command, | 73 | + * symlink/device entries are rejected rather than materialised. |
| 64 | - * and the SAME tool lists then extracts so the validated names match what's | ||
| 65 | - * written. The entry list is checked for path-traversal up-front. | ||
| 66 | - * | ||
| 67 | - * Note: the CLI tools may still materialise symlink entries (their targets are | ||
| 68 | - * not inspected) — unavoidable without a pure-JS zip reader; the pure-JS | ||
| 69 | - * tar/tar.gz path does block symlinks. `.zip` is a secondary dump format. | ||
| 70 | */ | 74 | */ |
| 71 | async function extractZip(archivePath: string, extractDir: string): Promise<void> { | 75 | async function extractZip(archivePath: string, extractDir: string): Promise<void> { |
| 72 | - // Probe whether tar can READ this zip (GNU tar can't); only the listing step | 76 | + const zipfile = await yauzl.openPromise(archivePath, { |
| 73 | - // decides the tool. Once a tool is chosen it both lists and extracts, and an | 77 | + decodeStrings: true, |
| 74 | - // EXTRACTION failure propagates instead of falling through to the other tool | 78 | + validateEntrySizes: true, |
| 75 | - // (which would double-extract / overwrite and mask the real error). | 79 | + strictFileNames: false, |
| 76 | - let tarListing: string | undefined; | 80 | + }); |
| 77 | - try { | ||
| 78 | - const { stdout } = await execFileAsync('tar', ['-tf', archivePath], EXEC_OPTS); | ||
| 79 | - tarListing = stdout; | ||
| 80 | - } catch { | ||
| 81 | - tarListing = undefined; // tar absent or can't read zip → fall back to unzip | ||
| 82 | - } | ||
| 83 | 81 | ||
| 84 | - if (tarListing !== undefined) { | ||
| 85 | - assertSafeEntryNames(tarListing.split('\n')); // zip-slip throws propagate | ||
| 86 | - await execFileAsync('tar', ['-xf', archivePath, '-C', extractDir], EXEC_OPTS); | ||
| 87 | - return; | ||
| 88 | - } | ||
| 89 | - | ||
| 90 | - let unzipListing: string; | ||
| 91 | try { | 82 | try { |
| 92 | - const { stdout } = await execFileAsync('unzip', ['-Z1', archivePath], EXEC_OPTS); | 83 | + let count = 0; |
| 93 | - unzipListing = stdout; | 84 | + let total = 0; |
| 94 | - } catch (e) { | 85 | + for await (const entry of zipfile.eachEntry()) { |
| 95 | - const err = e as { message?: string }; | 86 | + if (++count > DEFAULT_MAX_ENTRIES) { |
| 96 | - const msg = err.message || String(e); | 87 | + throw new Error(`归档条目过多(> ${DEFAULT_MAX_ENTRIES}),疑似解压炸弹`); |
| 97 | - if (process.platform === 'win32') { | 88 | + } |
| 98 | - throw new Error( | 89 | + total += entry.uncompressedSize; |
| 99 | - `无法读取 zip 包。Windows 10 1803+ 自带 tar 可读取 zip;若仍失败,请先手动解压该 zip,再用「打开文件夹」导入解压后的目录。(${msg})`, | 90 | + if (total > DEFAULT_MAX_TOTAL_BYTES) { |
| 100 | - ); | 91 | + throw new Error(`解压总大小超过上限(> ${DEFAULT_MAX_TOTAL_BYTES} 字节),疑似解压炸弹`); |
| 92 | + } | ||
| 93 | + | ||
| 94 | + const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff; | ||
| 95 | + const unixType = unixMode & 0o170000; | ||
| 96 | + if (unixType === 0o120000) { | ||
| 97 | + throw new Error(`归档包含不允许的符号链接: ${entry.fileName}`); | ||
| 98 | + } | ||
| 99 | + if (unixType !== 0 && unixType !== 0o040000 && unixType !== 0o100000) { | ||
| 100 | + throw new Error(`归档包含不支持的特殊文件: ${entry.fileName}`); | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + const isDirectory = entry.fileName.endsWith('/') || unixType === 0o040000; | ||
| 104 | + const normalizedName = isDirectory ? entry.fileName.replace(/[\\/]+$/, '') : entry.fileName; | ||
| 105 | + if (!normalizedName) continue; | ||
| 106 | + const target = safeZipJoin(extractDir, normalizedName); | ||
| 107 | + if (isDirectory) { | ||
| 108 | + fs.mkdirSync(target, { recursive: true }); | ||
| 109 | + continue; | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + fs.mkdirSync(path.dirname(target), { recursive: true }); | ||
| 113 | + const input = (await zipfile.openReadStreamPromise(entry)) as Readable; | ||
| 114 | + await pipeline(input, fs.createWriteStream(target, { flags: 'w', mode: 0o600 })); | ||
| 101 | } | 115 | } |
| 102 | - throw new Error(`无法读取 zip 包(需要系统 tar 或 unzip,请安装其一): ${msg}`); | 116 | + } finally { |
| 117 | + zipfile.close(); | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +export interface ExtractArchiveOptions { | ||
| 122 | + /** Parent directory for the private `bmc-log-*` extraction directory. */ | ||
| 123 | + destinationParent?: string; | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +export interface ArchiveCacheCleanupOptions { | ||
| 127 | + maxAgeMs?: number; | ||
| 128 | + maxEntries?: number; | ||
| 129 | + now?: number; | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +/** Remove expired/excess successful extraction directories from a private cache root. */ | ||
| 133 | +export function cleanupArchiveCache( | ||
| 134 | + cacheRoot: string, | ||
| 135 | + options: ArchiveCacheCleanupOptions = {}, | ||
| 136 | +): void { | ||
| 137 | + if (!fs.existsSync(cacheRoot)) return; | ||
| 138 | + const maxAgeMs = options.maxAgeMs ?? 30 * 24 * 60 * 60 * 1000; | ||
| 139 | + const maxEntries = options.maxEntries ?? 20; | ||
| 140 | + const now = options.now ?? Date.now(); | ||
| 141 | + const entries: Array<{ path: string; mtimeMs: number }> = []; | ||
| 142 | + | ||
| 143 | + for (const entry of fs.readdirSync(cacheRoot, { withFileTypes: true })) { | ||
| 144 | + if (!entry.isDirectory() || !entry.name.startsWith('bmc-log-')) continue; | ||
| 145 | + const entryPath = path.join(cacheRoot, entry.name); | ||
| 146 | + try { | ||
| 147 | + const mtimeMs = fs.statSync(entryPath).mtimeMs; | ||
| 148 | + if (now - mtimeMs > maxAgeMs) { | ||
| 149 | + fs.rmSync(entryPath, { recursive: true, force: true }); | ||
| 150 | + } else { | ||
| 151 | + entries.push({ path: entryPath, mtimeMs }); | ||
| 152 | + } | ||
| 153 | + } catch { | ||
| 154 | + // Another window may be creating/removing the same cache entry. | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); | ||
| 159 | + for (const entry of entries.slice(Math.max(0, maxEntries))) { | ||
| 160 | + fs.rmSync(entry.path, { recursive: true, force: true }); | ||
| 103 | } | 161 | } |
| 104 | - assertSafeEntryNames(unzipListing.split('\n')); | ||
| 105 | - await execFileAsync('unzip', ['-o', '-q', archivePath, '-d', extractDir], EXEC_OPTS); | ||
| 106 | } | 162 | } |
| 107 | 163 | ||
| 108 | /** | 164 | /** |
| 109 | * Extract an archive into a fresh temp directory and return its dump root. | 165 | * Extract an archive into a fresh temp directory and return its dump root. |
| 110 | * | 166 | * |
| 111 | - * `.tar` / `.tar.gz` / `.tgz` are handled by a self-contained pure-JS reader | 167 | + * All formats are handled by self-contained pure-JS readers — no shell and no |
| 112 | - * (`backend/parsers/tar`) — no shell, no `tar`/`unzip` CLI dependency, so it | 168 | + * `tar`/`unzip` CLI dependency, so extraction |
| 113 | * behaves identically on every platform and is immune to shell injection and | 169 | * behaves identically on every platform and is immune to shell injection and |
| 114 | * zip-slip (entries that escape the extract dir, symlinks, and zip-bombs are | 170 | * zip-slip (entries that escape the extract dir, symlinks, and zip-bombs are |
| 115 | - * rejected). `.zip` falls back to the system `tar` via `execFile` (no shell) | 171 | + * rejected). Throws on unsupported format or failure. |
| 116 | - * with the entry list validated up-front. Throws on unsupported format or failure. | ||
| 117 | */ | 172 | */ |
| 118 | -export async function extractArchive(archivePath: string): Promise<string> { | 173 | +export async function extractArchive( |
| 119 | - const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmc-log-')); | 174 | + archivePath: string, |
| 175 | + options: ExtractArchiveOptions = {}, | ||
| 176 | +): Promise<string> { | ||
| 177 | + const destinationParent = options.destinationParent ?? os.tmpdir(); | ||
| 178 | + fs.mkdirSync(destinationParent, { recursive: true }); | ||
| 179 | + const extractDir = fs.mkdtempSync(path.join(destinationParent, 'bmc-log-')); | ||
| 120 | const lower = path.basename(archivePath).toLowerCase(); | 180 | const lower = path.basename(archivePath).toLowerCase(); |
| 121 | 181 | ||
| 122 | try { | 182 | try { |
| @@ -1,45 +1,117 @@ | |||
| 1 | import * as vscode from 'vscode'; | 1 | import * as vscode from 'vscode'; |
| 2 | import * as fs from 'fs'; | 2 | import * as fs from 'fs'; |
| 3 | import * as path from 'path'; | 3 | import * as path from 'path'; |
| 4 | -import { RecentDumpsProvider, recordDump, removeDump, clearDumps } from './recentDumpsProvider'; | 4 | +import { |
| 5 | -import { extractArchive, isArchive } from './archive'; | 5 | + RecentDumpsProvider, |
| 6 | + recordDump, | ||
| 7 | + removeDump, | ||
| 8 | + clearDumps, | ||
| 9 | + type DumpRecord, | ||
| 10 | +} from './recentDumpsProvider'; | ||
| 11 | +import type { DumpHistoryEntry } from './recentDumpsModel'; | ||
| 12 | +import { cleanupArchiveCache, extractArchive, isArchive } from './archive'; | ||
| 6 | import { openV2Panel } from './host/commands'; | 13 | import { openV2Panel } from './host/commands'; |
| 7 | import { setMcpContext, registerMcpTools } from './mcp/register'; | 14 | import { setMcpContext, registerMcpTools } from './mcp/register'; |
| 15 | +import { | ||
| 16 | + defaultDialogUri, | ||
| 17 | + extensionHostSummary, | ||
| 18 | + toHostFsPath, | ||
| 19 | + uriForHostPath, | ||
| 20 | +} from './resourceUri'; | ||
| 8 | 21 | ||
| 9 | let recentProvider: RecentDumpsProvider | undefined; | 22 | let recentProvider: RecentDumpsProvider | undefined; |
| 23 | +let output: vscode.OutputChannel | undefined; | ||
| 24 | + | ||
| 25 | +function archiveCacheRoot(context: vscode.ExtensionContext): string { | ||
| 26 | + return path.join(context.globalStorageUri.fsPath, 'archive-cache'); | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function reportError(prefix: string, error: unknown): void { | ||
| 30 | + const message = error instanceof Error ? error.message : String(error); | ||
| 31 | + output?.appendLine(`[error] ${prefix}: ${message}`); | ||
| 32 | + vscode.window.showErrorMessage(`${prefix}: ${message}`); | ||
| 33 | +} | ||
| 10 | 34 | ||
| 11 | export function activate(context: vscode.ExtensionContext): { | 35 | export function activate(context: vscode.ExtensionContext): { |
| 12 | registerMcpTools: typeof registerMcpTools; | 36 | registerMcpTools: typeof registerMcpTools; |
| 13 | } { | 37 | } { |
| 38 | + output = vscode.window.createOutputChannel('BMC Log Reviewer'); | ||
| 39 | + context.subscriptions.push(output); | ||
| 40 | + output.appendLine(`[activate] ${extensionHostSummary()}`); | ||
| 41 | + | ||
| 42 | + const cacheRoot = archiveCacheRoot(context); | ||
| 43 | + try { | ||
| 44 | + fs.mkdirSync(cacheRoot, { recursive: true }); | ||
| 45 | + cleanupArchiveCache(cacheRoot); | ||
| 46 | + } catch (error) { | ||
| 47 | + output.appendLine(`[warning] 归档缓存初始化失败: ${String(error)}`); | ||
| 48 | + } | ||
| 49 | + | ||
| 14 | setMcpContext(context); | 50 | setMcpContext(context); |
| 15 | recentProvider = new RecentDumpsProvider(context); | 51 | recentProvider = new RecentDumpsProvider(context); |
| 16 | context.subscriptions.push( | 52 | context.subscriptions.push( |
| 17 | vscode.window.registerTreeDataProvider('logReviewer.recent', recentProvider), | 53 | vscode.window.registerTreeDataProvider('logReviewer.recent', recentProvider), |
| 18 | 54 | ||
| 19 | vscode.commands.registerCommand('newlogtool.openDump', async (uri?: vscode.Uri) => { | 55 | vscode.commands.registerCommand('newlogtool.openDump', async (uri?: vscode.Uri) => { |
| 20 | - const dumpDir = await pickDumpDir(context, uri); | 56 | + const target = await pickDumpDir(uri); |
| 21 | - if (!dumpDir) return; | 57 | + if (!target) return; |
| 58 | + if (!openDumpDir(context, target.path)) return; | ||
| 59 | + | ||
| 60 | + recordDump(context, { | ||
| 61 | + ...target, | ||
| 62 | + kind: 'folder', | ||
| 63 | + label: path.basename(target.path), | ||
| 64 | + }); | ||
| 22 | recentProvider?.refresh(); | 65 | recentProvider?.refresh(); |
| 23 | - openDumpDir(context, dumpDir); | ||
| 24 | }), | 66 | }), |
| 25 | 67 | ||
| 26 | - // Triggered by clicking a recent-dumps tree item. | 68 | + // Triggered by clicking a recent-dumps tree item. Legacy callers may still |
| 27 | - vscode.commands.registerCommand('newlogtool.openRecent', (dumpPath: string, label?: string) => { | 69 | + // pass `(path, label)`, while v2 tree items pass the full URI-aware entry. |
| 28 | - if (!dumpPath) return; | 70 | + vscode.commands.registerCommand( |
| 29 | - recordDump(context, dumpPath, label || path.basename(dumpPath)); | 71 | + 'newlogtool.openRecent', |
| 30 | - recentProvider?.refresh(); | 72 | + async (value: DumpHistoryEntry | string, legacyLabel?: string) => { |
| 31 | - openDumpDir(context, dumpPath); | 73 | + if (!value) return; |
| 32 | - }), | 74 | + let entry: DumpHistoryEntry = |
| 75 | + typeof value === 'string' | ||
| 76 | + ? { | ||
| 77 | + path: value, | ||
| 78 | + uri: uriForHostPath(value).toString(), | ||
| 79 | + label: legacyLabel || path.basename(value), | ||
| 80 | + openedAt: 0, | ||
| 81 | + } | ||
| 82 | + : value; | ||
| 83 | + | ||
| 84 | + if (!fs.existsSync(entry.path)) { | ||
| 85 | + if (!entry.sourceUri) { | ||
| 86 | + reportError('打开失败', `路径已不存在: ${entry.path}`); | ||
| 87 | + return; | ||
| 88 | + } | ||
| 89 | + let source: vscode.Uri; | ||
| 90 | + try { | ||
| 91 | + source = vscode.Uri.parse(entry.sourceUri, true); | ||
| 92 | + } catch { | ||
| 93 | + reportError('打开失败', `无效的压缩包 URI: ${entry.sourceUri}`); | ||
| 94 | + return; | ||
| 95 | + } | ||
| 96 | + const restored = await extractArchiveWithProgress(context, source); | ||
| 97 | + if (!restored) return; | ||
| 98 | + entry = { ...entry, path: restored.path, uri: restored.uri }; | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + if (!openDumpDir(context, entry.path)) return; | ||
| 102 | + recordDump(context, entry); | ||
| 103 | + recentProvider?.refresh(); | ||
| 104 | + }, | ||
| 105 | + ), | ||
| 33 | 106 | ||
| 34 | // Remove a single recent item (inline trash icon on the tree item). | 107 | // Remove a single recent item (inline trash icon on the tree item). |
| 35 | vscode.commands.registerCommand('newlogtool.removeRecent', (item?: vscode.TreeItem) => { | 108 | vscode.commands.registerCommand('newlogtool.removeRecent', (item?: vscode.TreeItem) => { |
| 36 | - const p = item?.resourceUri?.fsPath; | 109 | + const identifier = item?.id || item?.resourceUri?.toString() || item?.resourceUri?.fsPath; |
| 37 | - if (!p) return; | 110 | + if (!identifier) return; |
| 38 | - removeDump(context, p); | 111 | + removeDump(context, identifier); |
| 39 | recentProvider?.refresh(); | 112 | recentProvider?.refresh(); |
| 40 | }), | 113 | }), |
| 41 | 114 | ||
| 42 | - // Clear the whole recent list (title-bar action, with confirm). | ||
| 43 | vscode.commands.registerCommand('newlogtool.clearRecent', async () => { | 115 | vscode.commands.registerCommand('newlogtool.clearRecent', async () => { |
| 44 | const choice = await vscode.window.showWarningMessage( | 116 | const choice = await vscode.window.showWarningMessage( |
| 45 | '清空"最近打开"列表?', | 117 | '清空"最近打开"列表?', |
| @@ -51,11 +123,11 @@ export function activate(context: vscode.ExtensionContext): { | |||
| 51 | recentProvider?.refresh(); | 123 | recentProvider?.refresh(); |
| 52 | }), | 124 | }), |
| 53 | 125 | ||
| 54 | - // Pick (or right-click) a .tar.gz/.tgz/.tar/.zip log archive, extract it, then open. | ||
| 55 | vscode.commands.registerCommand('newlogtool.openArchive', async (uri?: vscode.Uri) => { | 126 | vscode.commands.registerCommand('newlogtool.openArchive', async (uri?: vscode.Uri) => { |
| 56 | - let archivePath = uri?.fsPath; | 127 | + let archiveUri = uri; |
| 57 | - if (!archivePath || !isArchive(archivePath)) { | 128 | + if (!archiveUri || !isArchive(archiveUri.path)) { |
| 58 | const picked = await vscode.window.showOpenDialog({ | 129 | const picked = await vscode.window.showOpenDialog({ |
| 130 | + defaultUri: defaultDialogUri(), | ||
| 59 | canSelectFiles: true, | 131 | canSelectFiles: true, |
| 60 | canSelectFolders: false, | 132 | canSelectFolders: false, |
| 61 | canSelectMany: false, | 133 | canSelectMany: false, |
| @@ -64,32 +136,26 @@ export function activate(context: vscode.ExtensionContext): { | |||
| 64 | openLabel: '打开压缩包', | 136 | openLabel: '打开压缩包', |
| 65 | }); | 137 | }); |
| 66 | if (!picked?.[0]) return; | 138 | if (!picked?.[0]) return; |
| 67 | - archivePath = picked[0].fsPath; | 139 | + archiveUri = picked[0]; |
| 68 | } | 140 | } |
| 69 | 141 | ||
| 70 | - const finalPath = archivePath; | 142 | + const target = await extractArchiveWithProgress(context, archiveUri); |
| 71 | - const dumpRoot = await vscode.window.withProgress( | 143 | + if (!target || !openDumpDir(context, target.path)) return; |
| 72 | - { | ||
| 73 | - location: vscode.ProgressLocation.Notification, | ||
| 74 | - title: `正在解压 ${path.basename(finalPath)} …`, | ||
| 75 | - }, | ||
| 76 | - async () => { | ||
| 77 | - try { | ||
| 78 | - return await extractArchive(finalPath); | ||
| 79 | - } catch (e: unknown) { | ||
| 80 | - const err = e as { message?: string }; | ||
| 81 | - vscode.window.showErrorMessage(`解压失败: ${err.message || String(e)}`); | ||
| 82 | - return undefined; | ||
| 83 | - } | ||
| 84 | - }, | ||
| 85 | - ); | ||
| 86 | - if (!dumpRoot) return; | ||
| 87 | 144 | ||
| 88 | - // Label the recent entry by the archive filename (the extract dir is a | 145 | + let archivePath: string; |
| 89 | - // throwaway `…/bmc-log-xxxx/dump_info`, which looks identical for every dump). | 146 | + try { |
| 90 | - recordDump(context, dumpRoot, path.basename(finalPath)); | 147 | + archivePath = toHostFsPath(archiveUri); |
| 148 | + } catch (error) { | ||
| 149 | + reportError('打开失败', error); | ||
| 150 | + return; | ||
| 151 | + } | ||
| 152 | + recordDump(context, { | ||
| 153 | + ...target, | ||
| 154 | + sourceUri: archiveUri.toString(), | ||
| 155 | + kind: 'archive', | ||
| 156 | + label: path.basename(archivePath), | ||
| 157 | + }); | ||
| 91 | recentProvider?.refresh(); | 158 | recentProvider?.refresh(); |
| 92 | - openDumpDir(context, dumpRoot); | ||
| 93 | }), | 159 | }), |
| 94 | 160 | ||
| 95 | vscode.commands.registerCommand('newlogtool.refreshRecent', () => recentProvider?.refresh()), | 161 | vscode.commands.registerCommand('newlogtool.refreshRecent', () => recentProvider?.refresh()), |
| @@ -100,42 +166,76 @@ export function activate(context: vscode.ExtensionContext): { | |||
| 100 | } | 166 | } |
| 101 | 167 | ||
| 102 | /** Open the dump in the Vue (V2) webview. */ | 168 | /** Open the dump in the Vue (V2) webview. */ |
| 103 | -function openDumpDir(context: vscode.ExtensionContext, dumpDir: string): void { | 169 | +function openDumpDir(context: vscode.ExtensionContext, dumpDir: string): boolean { |
| 104 | - openV2Panel(context, dumpDir); | 170 | + return openV2Panel(context, dumpDir); |
| 105 | } | 171 | } |
| 106 | 172 | ||
| 107 | async function pickDumpDir( | 173 | async function pickDumpDir( |
| 108 | - context: vscode.ExtensionContext, | ||
| 109 | uri?: vscode.Uri, | 174 | uri?: vscode.Uri, |
| 110 | -): Promise<string | undefined> { | 175 | +): Promise<Pick<DumpRecord, 'path' | 'uri'> | undefined> { |
| 111 | - // A context-menu URI may point at a stale path (recent-dumps tracks deleted | 176 | + let selected = uri; |
| 112 | - // dumps) — statSync would throw, so guard it and fall through to the picker. | 177 | + if (!selected) { |
| 113 | - if (uri) { | 178 | + const folders = await vscode.window.showOpenDialog({ |
| 114 | - let isDir = false; | 179 | + defaultUri: defaultDialogUri(), |
| 115 | - try { | 180 | + canSelectFolders: true, |
| 116 | - isDir = fs.statSync(uri.fsPath).isDirectory(); | 181 | + canSelectMany: false, |
| 117 | - } catch { | 182 | + title: '选择 BMC 一键日志目录(包含 AppDump 子目录的文件夹)', |
| 118 | - isDir = false; | 183 | + openLabel: '选择文件夹', |
| 119 | - } | 184 | + }); |
| 120 | - if (isDir) { | 185 | + selected = folders?.[0]; |
| 121 | - recordDump(context, uri.fsPath, path.basename(uri.fsPath)); | 186 | + } |
| 122 | - return uri.fsPath; | 187 | + if (!selected) return undefined; |
| 188 | + | ||
| 189 | + try { | ||
| 190 | + const selectedPath = toHostFsPath(selected); | ||
| 191 | + if (!fs.statSync(selectedPath).isDirectory()) { | ||
| 192 | + throw new Error(`所选资源不是目录: ${selected.toString()}`); | ||
| 123 | } | 193 | } |
| 194 | + output?.appendLine(`[open-folder] uri=${selected.toString()} path=${selectedPath}`); | ||
| 195 | + return { path: selectedPath, uri: selected.toString() }; | ||
| 196 | + } catch (error) { | ||
| 197 | + reportError('无法访问所选日志目录', error); | ||
| 198 | + return undefined; | ||
| 199 | + } | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +async function extractArchiveWithProgress( | ||
| 203 | + context: vscode.ExtensionContext, | ||
| 204 | + archiveUri: vscode.Uri, | ||
| 205 | +): Promise<Pick<DumpRecord, 'path' | 'uri'> | undefined> { | ||
| 206 | + let archivePath: string; | ||
| 207 | + try { | ||
| 208 | + archivePath = toHostFsPath(archiveUri); | ||
| 209 | + if (!isArchive(archivePath)) throw new Error(`不支持的压缩格式: ${archivePath}`); | ||
| 210 | + if (!fs.statSync(archivePath).isFile()) throw new Error(`所选资源不是文件: ${archivePath}`); | ||
| 211 | + } catch (error) { | ||
| 212 | + reportError('无法访问所选压缩包', error); | ||
| 213 | + return undefined; | ||
| 124 | } | 214 | } |
| 125 | 215 | ||
| 126 | - const folders = await vscode.window.showOpenDialog({ | 216 | + output?.appendLine(`[open-archive] uri=${archiveUri.toString()} path=${archivePath}`); |
| 127 | - canSelectFolders: true, | 217 | + const dumpRoot = await vscode.window.withProgress( |
| 128 | - canSelectMany: false, | 218 | + { |
| 129 | - title: '选择 BMC 一键日志目录(包含 AppDump 子目录的文件夹)', | 219 | + location: vscode.ProgressLocation.Notification, |
| 130 | - openLabel: '选择文件夹', | 220 | + title: `正在解压 ${path.basename(archivePath)} …`, |
| 131 | - }); | 221 | + }, |
| 222 | + async () => { | ||
| 223 | + try { | ||
| 224 | + return await extractArchive(archivePath, { | ||
| 225 | + destinationParent: archiveCacheRoot(context), | ||
| 226 | + }); | ||
| 227 | + } catch (error) { | ||
| 228 | + reportError('解压失败', error); | ||
| 229 | + return undefined; | ||
| 230 | + } | ||
| 231 | + }, | ||
| 232 | + ); | ||
| 233 | + if (!dumpRoot) return undefined; | ||
| 132 | 234 | ||
| 133 | - if (folders?.[0]) { | 235 | + return { |
| 134 | - recordDump(context, folders[0].fsPath, path.basename(folders[0].fsPath)); | 236 | + path: dumpRoot, |
| 135 | - return folders[0].fsPath; | 237 | + uri: uriForHostPath(dumpRoot, archiveUri).toString(), |
| 136 | - } | 238 | + }; |
| 137 | - | ||
| 138 | - return undefined; | ||
| 139 | } | 239 | } |
| 140 | 240 | ||
| 141 | export function deactivate(): void {} | 241 | export function deactivate(): void {} |
| @@ -5,9 +5,9 @@ import { isApiError } from '../shared/api-error'; | |||
| 5 | import type { GenericInfoResponse } from '../shared/types/overview'; | 5 | import type { GenericInfoResponse } from '../shared/types/overview'; |
| 6 | 6 | ||
| 7 | /** 压缩包→解压得 dump 根;目录→定位 AppDump 根(找不到则按原样)。 */ | 7 | /** 压缩包→解压得 dump 根;目录→定位 AppDump 根(找不到则按原样)。 */ |
| 8 | -export async function resolveDumpDir(p: string): Promise<string> { | 8 | +export async function resolveDumpDir(p: string, destinationParent?: string): Promise<string> { |
| 9 | if (!p) throw new Error('path 不能为空'); | 9 | if (!p) throw new Error('path 不能为空'); |
| 10 | - if (isArchive(p)) return extractArchive(p); | 10 | + if (isArchive(p)) return extractArchive(p, { destinationParent }); |
| 11 | return findDumpRoot(p) ?? p; | 11 | return findDumpRoot(p) ?? p; |
| 12 | } | 12 | } |
| 13 | 13 | ||
| @@ -4,6 +4,7 @@ import { resolveDumpDir, getSection, diagnose, buildOrientation, SECTIONS } from | |||
| 4 | import { openV2Panel } from '../host/commands'; | 4 | import { openV2Panel } from '../host/commands'; |
| 5 | import { isArchive } from '../archive'; | 5 | import { isArchive } from '../archive'; |
| 6 | import { recordDump, listRecentDumps } from '../recentDumpsProvider'; | 6 | import { recordDump, listRecentDumps } from '../recentDumpsProvider'; |
| 7 | +import { uriForHostPath } from '../resourceUri'; | ||
| 7 | 8 | ||
| 8 | // host 调 registerMcpTools 时不传 ExtensionContext;activate() 通过 setMcpContext 注入。 | 9 | // host 调 registerMcpTools 时不传 ExtensionContext;activate() 通过 setMcpContext 注入。 |
| 9 | let extensionContext: vscode.ExtensionContext | undefined; | 10 | let extensionContext: vscode.ExtensionContext | undefined; |
| @@ -68,14 +69,21 @@ export function registerMcpTools(registry: McpRegistry): void { | |||
| 68 | async (args) => { | 69 | async (args) => { |
| 69 | if (!extensionContext) throw new Error('扩展未就绪(context 未注入)'); | 70 | if (!extensionContext) throw new Error('扩展未就绪(context 未注入)'); |
| 70 | const rawPath = String(args.path ?? ''); | 71 | const rawPath = String(args.path ?? ''); |
| 71 | - const dumpDir = await resolveDumpDir(rawPath); | 72 | + const archiveCacheRoot = path.join(extensionContext.globalStorageUri.fsPath, 'archive-cache'); |
| 73 | + const dumpDir = await resolveDumpDir(rawPath, archiveCacheRoot); | ||
| 72 | if (!openV2Panel(extensionContext, dumpDir)) { | 74 | if (!openV2Panel(extensionContext, dumpDir)) { |
| 73 | throw new Error(`${dumpDir} 下未找到 AppDump 目录,无法打开可视化面板`); | 75 | throw new Error(`${dumpDir} 下未找到 AppDump 目录,无法打开可视化面板`); |
| 74 | } | 76 | } |
| 75 | // 与 UI 命令一致:记入"最近打开"并刷新 treeview。压缩包以原文件名为标签 | 77 | // 与 UI 命令一致:记入"最近打开"并刷新 treeview。压缩包以原文件名为标签 |
| 76 | // (解压目录名对每个 dump 都长得一样)。 | 78 | // (解压目录名对每个 dump 都长得一样)。 |
| 77 | - const label = isArchive(rawPath) ? path.basename(rawPath) : path.basename(dumpDir); | 79 | + const fromArchive = isArchive(rawPath); |
| 78 | - recordDump(extensionContext, dumpDir, label); | 80 | + recordDump(extensionContext, { |
| 81 | + path: dumpDir, | ||
| 82 | + uri: uriForHostPath(dumpDir).toString(), | ||
| 83 | + sourceUri: fromArchive ? uriForHostPath(rawPath).toString() : undefined, | ||
| 84 | + kind: fromArchive ? 'archive' : 'folder', | ||
| 85 | + label: fromArchive ? path.basename(rawPath) : path.basename(dumpDir), | ||
| 86 | + }); | ||
| 79 | await vscode.commands.executeCommand('newlogtool.refreshRecent'); | 87 | await vscode.commands.executeCommand('newlogtool.refreshRecent'); |
| 80 | return buildOrientation(dumpDir); | 88 | return buildOrientation(dumpDir); |
| 81 | }, | 89 | }, |
| @@ -10,10 +10,18 @@ export const DUMP_HISTORY_KEY = 'newlogtool.dumpHistory'; | |||
| 10 | 10 | ||
| 11 | export const MAX_HISTORY = 30; | 11 | export const MAX_HISTORY = 30; |
| 12 | 12 | ||
| 13 | +export type DumpHistoryKind = 'folder' | 'archive'; | ||
| 14 | + | ||
| 13 | /** One remembered dump: the opened directory + a friendly label + when it was opened. */ | 15 | /** One remembered dump: the opened directory + a friendly label + when it was opened. */ |
| 14 | export interface DumpHistoryEntry { | 16 | export interface DumpHistoryEntry { |
| 15 | /** Dump directory actually opened (an extracted temp dir, or a picked folder). */ | 17 | /** Dump directory actually opened (an extracted temp dir, or a picked folder). */ |
| 16 | path: string; | 18 | path: string; |
| 19 | + /** Full VS Code resource URI, preserving a Remote-WSL/SSH authority when present. */ | ||
| 20 | + uri?: string; | ||
| 21 | + /** Original archive URI. Allows a missing extracted cache to be rebuilt. */ | ||
| 22 | + sourceUri?: string; | ||
| 23 | + /** Whether the user opened a directory or an archive. Missing on legacy entries. */ | ||
| 24 | + kind?: DumpHistoryKind; | ||
| 17 | /** Display name — the archive filename (e.g. `dump.tar.gz`) or the folder name. */ | 25 | /** Display name — the archive filename (e.g. `dump.tar.gz`) or the folder name. */ |
| 18 | label: string; | 26 | label: string; |
| 19 | /** ms epoch the dump was last opened; 0 for entries migrated from the old format. */ | 27 | /** ms epoch the dump was last opened; 0 for entries migrated from the old format. */ |
| @@ -61,13 +69,22 @@ export function normalizeHistory(raw: unknown): DumpHistoryEntry[] { | |||
| 61 | } else if (item && typeof item === 'object') { | 69 | } else if (item && typeof item === 'object') { |
| 62 | const e = item as Record<string, unknown>; | 70 | const e = item as Record<string, unknown>; |
| 63 | if (typeof e.path === 'string') { | 71 | if (typeof e.path === 'string') { |
| 64 | - out.push({ | 72 | + const entry: DumpHistoryEntry = { |
| 65 | path: e.path, | 73 | path: e.path, |
| 66 | label: typeof e.label === 'string' && e.label ? e.label : path.basename(e.path) || e.path, | 74 | label: typeof e.label === 'string' && e.label ? e.label : path.basename(e.path) || e.path, |
| 67 | openedAt: typeof e.openedAt === 'number' && Number.isFinite(e.openedAt) ? e.openedAt : 0, | 75 | openedAt: typeof e.openedAt === 'number' && Number.isFinite(e.openedAt) ? e.openedAt : 0, |
| 68 | - }); | 76 | + }; |
| 77 | + if (typeof e.uri === 'string' && e.uri) entry.uri = e.uri; | ||
| 78 | + if (typeof e.sourceUri === 'string' && e.sourceUri) entry.sourceUri = e.sourceUri; | ||
| 79 | + if (e.kind === 'folder' || e.kind === 'archive') entry.kind = e.kind; | ||
| 80 | + out.push(entry); | ||
| 69 | } | 81 | } |
| 70 | } | 82 | } |
| 71 | } | 83 | } |
| 72 | return out; | 84 | return out; |
| 73 | } | 85 | } |
| 86 | + | ||
| 87 | +/** Stable identity used for history dedup/removal across extraction cache refreshes. */ | ||
| 88 | +export function historyKey(entry: Pick<DumpHistoryEntry, 'path' | 'uri' | 'sourceUri'>): string { | ||
| 89 | + return entry.sourceUri || entry.uri || entry.path; | ||
| 90 | +} | ||
| @@ -5,10 +5,13 @@ import { | |||
| 5 | MAX_HISTORY, | 5 | MAX_HISTORY, |
| 6 | BUCKETS, | 6 | BUCKETS, |
| 7 | bucketOf, | 7 | bucketOf, |
| 8 | + historyKey, | ||
| 8 | normalizeHistory, | 9 | normalizeHistory, |
| 9 | type Bucket, | 10 | type Bucket, |
| 10 | type DumpHistoryEntry, | 11 | type DumpHistoryEntry, |
| 12 | + type DumpHistoryKind, | ||
| 11 | } from './recentDumpsModel'; | 13 | } from './recentDumpsModel'; |
| 14 | +import { toHostFsPath, uriForHostPath } from './resourceUri'; | ||
| 12 | 15 | ||
| 13 | function readHistory(context: vscode.ExtensionContext): DumpHistoryEntry[] { | 16 | function readHistory(context: vscode.ExtensionContext): DumpHistoryEntry[] { |
| 14 | return normalizeHistory(context.globalState.get(DUMP_HISTORY_KEY, [])); | 17 | return normalizeHistory(context.globalState.get(DUMP_HISTORY_KEY, [])); |
| @@ -25,21 +28,56 @@ export function listRecentDumps(context: vscode.ExtensionContext): DumpHistoryEn | |||
| 25 | return readHistory(context).filter((e) => fs.existsSync(e.path)); | 28 | return readHistory(context).filter((e) => fs.existsSync(e.path)); |
| 26 | } | 29 | } |
| 27 | 30 | ||
| 28 | -/** Record (or bump to most-recent) a dump open. Dedup is by `path`. */ | 31 | +export interface DumpRecord { |
| 29 | -export function recordDump( | 32 | + path: string; |
| 30 | - context: vscode.ExtensionContext, | 33 | + uri?: string; |
| 31 | - dumpPath: string, | 34 | + sourceUri?: string; |
| 32 | - label: string, | 35 | + kind?: DumpHistoryKind; |
| 33 | -): void { | 36 | + label: string; |
| 34 | - const entries = readHistory(context).filter((e) => e.path !== dumpPath); | 37 | +} |
| 35 | - entries.unshift({ path: dumpPath, label, openedAt: Date.now() }); | 38 | + |
| 39 | +function parseStoredUri(value: string | undefined): vscode.Uri | undefined { | ||
| 40 | + if (!value) return undefined; | ||
| 41 | + try { | ||
| 42 | + return vscode.Uri.parse(value, true); | ||
| 43 | + } catch { | ||
| 44 | + return undefined; | ||
| 45 | + } | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +function sourceExists(entry: DumpHistoryEntry): boolean { | ||
| 49 | + const source = parseStoredUri(entry.sourceUri); | ||
| 50 | + if (!source) return false; | ||
| 51 | + try { | ||
| 52 | + return fs.existsSync(toHostFsPath(source)); | ||
| 53 | + } catch { | ||
| 54 | + return false; | ||
| 55 | + } | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +/** Entries visible in the tree, including archives whose cache can be rebuilt. */ | ||
| 59 | +function listTreeDumps(context: vscode.ExtensionContext): DumpHistoryEntry[] { | ||
| 60 | + return readHistory(context).filter((entry) => fs.existsSync(entry.path) || sourceExists(entry)); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/** Record (or bump to most-recent) a dump open. Archives dedupe by their source URI. */ | ||
| 64 | +export function recordDump(context: vscode.ExtensionContext, record: DumpRecord): void { | ||
| 65 | + const key = historyKey(record); | ||
| 66 | + const entries = readHistory(context).filter((entry) => historyKey(entry) !== key); | ||
| 67 | + entries.unshift({ ...record, openedAt: Date.now() }); | ||
| 36 | if (entries.length > MAX_HISTORY) entries.length = MAX_HISTORY; | 68 | if (entries.length > MAX_HISTORY) entries.length = MAX_HISTORY; |
| 37 | context.globalState.update(DUMP_HISTORY_KEY, entries); | 69 | context.globalState.update(DUMP_HISTORY_KEY, entries); |
| 38 | } | 70 | } |
| 39 | 71 | ||
| 40 | /** Drop a single dump from the recent list. */ | 72 | /** Drop a single dump from the recent list. */ |
| 41 | -export function removeDump(context: vscode.ExtensionContext, dumpPath: string): void { | 73 | +export function removeDump(context: vscode.ExtensionContext, identifier: string): void { |
| 42 | - const entries = readHistory(context).filter((e) => e.path !== dumpPath); | 74 | + const entries = readHistory(context).filter( |
| 75 | + (entry) => | ||
| 76 | + historyKey(entry) !== identifier && | ||
| 77 | + entry.path !== identifier && | ||
| 78 | + entry.uri !== identifier && | ||
| 79 | + entry.sourceUri !== identifier, | ||
| 80 | + ); | ||
| 43 | context.globalState.update(DUMP_HISTORY_KEY, entries); | 81 | context.globalState.update(DUMP_HISTORY_KEY, entries); |
| 44 | } | 82 | } |
| 45 | 83 | ||
| @@ -72,7 +110,7 @@ export class RecentDumpsProvider implements vscode.TreeDataProvider<vscode.TreeI | |||
| 72 | if (element) return []; // a leaf has no children | 110 | if (element) return []; // a leaf has no children |
| 73 | 111 | ||
| 74 | // Top level: bucket the entries and surface only the non-empty groups, in order. | 112 | // Top level: bucket the entries and surface only the non-empty groups, in order. |
| 75 | - const entries = listRecentDumps(this.context); | 113 | + const entries = listTreeDumps(this.context); |
| 76 | const now = Date.now(); | 114 | const now = Date.now(); |
| 77 | const byBucket = new Map<Bucket, DumpHistoryEntry[]>(); | 115 | const byBucket = new Map<Bucket, DumpHistoryEntry[]>(); |
| 78 | for (const e of entries) { | 116 | for (const e of entries) { |
| @@ -99,18 +137,30 @@ class DumpItem extends vscode.TreeItem { | |||
| 99 | constructor(public readonly entry: DumpHistoryEntry) { | 137 | constructor(public readonly entry: DumpHistoryEntry) { |
| 100 | super(entry.label, vscode.TreeItemCollapsibleState.None); | 138 | super(entry.label, vscode.TreeItemCollapsibleState.None); |
| 101 | const exists = fs.existsSync(entry.path); | 139 | const exists = fs.existsSync(entry.path); |
| 102 | - this.tooltip = exists ? entry.path : `${entry.path}(路径已不存在)`; | 140 | + const canRestore = !exists && sourceExists(entry); |
| 103 | - this.description = exists ? undefined : '路径已不存在'; | 141 | + this.tooltip = exists |
| 104 | - this.resourceUri = vscode.Uri.file(entry.path); | 142 | + ? entry.path |
| 143 | + : canRestore | ||
| 144 | + ? `${entry.path}(缓存已清理,点击后从原压缩包重新解压)` | ||
| 145 | + : `${entry.path}(路径已不存在)`; | ||
| 146 | + this.description = exists ? undefined : canRestore ? '需重新解压' : '路径已不存在'; | ||
| 147 | + this.resourceUri = | ||
| 148 | + (exists ? parseStoredUri(entry.uri) : parseStoredUri(entry.sourceUri)) ?? | ||
| 149 | + uriForHostPath(entry.path); | ||
| 150 | + this.id = historyKey(entry); | ||
| 105 | this.iconPath = new vscode.ThemeIcon( | 151 | this.iconPath = new vscode.ThemeIcon( |
| 106 | - !exists ? 'warning' : ARCHIVE_LABEL_RE.test(entry.label) ? 'file-zip' : 'folder', | 152 | + !exists && !canRestore |
| 153 | + ? 'warning' | ||
| 154 | + : ARCHIVE_LABEL_RE.test(entry.label) | ||
| 155 | + ? 'file-zip' | ||
| 156 | + : 'folder', | ||
| 107 | ); | 157 | ); |
| 108 | this.contextValue = 'dumpItem'; | 158 | this.contextValue = 'dumpItem'; |
| 109 | // Clicking re-opens the dump (and bumps it to "今天" via openRecent). | 159 | // Clicking re-opens the dump (and bumps it to "今天" via openRecent). |
| 110 | this.command = { | 160 | this.command = { |
| 111 | command: 'newlogtool.openRecent', | 161 | command: 'newlogtool.openRecent', |
| 112 | title: '打开', | 162 | title: '打开', |
| 113 | - arguments: [entry.path, entry.label], | 163 | + arguments: [entry], |
| 114 | }; | 164 | }; |
| 115 | } | 165 | } |
| 116 | } | 166 | } |
| @@ -0,0 +1,55 @@ | |||
| 1 | +import * as vscode from 'vscode'; | ||
| 2 | + | ||
| 3 | +export const EXTENSION_ID = 'openubmc.bmcstudio-log-reviewer'; | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * Convert a VS Code resource URI to a path that is directly accessible from the | ||
| 7 | + * current Node extension host. Remote resources are valid only for a Workspace | ||
| 8 | + * extension running in the remote host; a UI extension would otherwise hand a | ||
| 9 | + * Linux path to the local Windows filesystem. | ||
| 10 | + */ | ||
| 11 | +export function toHostFsPath(uri: vscode.Uri): string { | ||
| 12 | + if (uri.scheme === 'file') return uri.fsPath; | ||
| 13 | + | ||
| 14 | + if (uri.scheme === 'vscode-remote') { | ||
| 15 | + const self = vscode.extensions.getExtension(EXTENSION_ID); | ||
| 16 | + if (self?.extensionKind === vscode.ExtensionKind.UI) { | ||
| 17 | + throw new Error( | ||
| 18 | + '所选日志位于远程工作区,但日志解析扩展正在本地 UI 宿主运行。' + | ||
| 19 | + '请把扩展安装到当前 WSL/远程环境后重载窗口。', | ||
| 20 | + ); | ||
| 21 | + } | ||
| 22 | + return uri.fsPath; | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + throw new Error(`不支持的文件系统类型: ${uri.scheme}(日志必须位于本地或远程物理文件系统)`); | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +/** Build a resource URI for a native path on the current extension host. */ | ||
| 29 | +export function uriForHostPath(nativePath: string, reference?: vscode.Uri): vscode.Uri { | ||
| 30 | + const fileUri = vscode.Uri.file(nativePath); | ||
| 31 | + const remoteReference = | ||
| 32 | + reference?.scheme === 'vscode-remote' | ||
| 33 | + ? reference | ||
| 34 | + : vscode.workspace.workspaceFolders?.find((folder) => folder.uri.scheme === 'vscode-remote') | ||
| 35 | + ?.uri; | ||
| 36 | + | ||
| 37 | + if (!remoteReference) return fileUri; | ||
| 38 | + return fileUri.with({ scheme: remoteReference.scheme, authority: remoteReference.authority }); | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** Prefer the active workspace so open dialogs stay inside WSL/SSH. */ | ||
| 42 | +export function defaultDialogUri(): vscode.Uri | undefined { | ||
| 43 | + return vscode.workspace.workspaceFolders?.[0]?.uri; | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +export function extensionHostSummary(): string { | ||
| 47 | + const self = vscode.extensions.getExtension(EXTENSION_ID); | ||
| 48 | + const kind = | ||
| 49 | + self?.extensionKind === vscode.ExtensionKind.Workspace | ||
| 50 | + ? 'workspace' | ||
| 51 | + : self?.extensionKind === vscode.ExtensionKind.UI | ||
| 52 | + ? 'ui' | ||
| 53 | + : 'unknown'; | ||
| 54 | + return `platform=${process.platform}, remote=${vscode.env.remoteName ?? 'local'}, extensionKind=${kind}`; | ||
| 55 | +} | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | +declare module 'yauzl' { | ||
| 2 | + import type { Readable } from 'stream'; | ||
| 3 | + | ||
| 4 | + export interface Entry { | ||
| 5 | + fileName: string; | ||
| 6 | + uncompressedSize: number; | ||
| 7 | + externalFileAttributes: number; | ||
| 8 | + } | ||
| 9 | + | ||
| 10 | + export interface ZipFile { | ||
| 11 | + eachEntry(): AsyncIterable<Entry>; | ||
| 12 | + openReadStreamPromise(entry: Entry): Promise<Readable>; | ||
| 13 | + close(): void; | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + export interface Options { | ||
| 17 | + decodeStrings?: boolean; | ||
| 18 | + validateEntrySizes?: boolean; | ||
| 19 | + strictFileNames?: boolean; | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + export function openPromise(path: string, options?: Options): Promise<ZipFile>; | ||
| 23 | +} | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | +interface YazlZipFile { | ||
| 2 | + outputStream: NodeJS.ReadableStream; | ||
| 3 | + addBuffer(buffer: Buffer, name: string, options?: { mode?: number }): void; | ||
| 4 | + end(): void; | ||
| 5 | +} | ||
| 6 | + | ||
| 7 | +const { ZipFile } = require('yazl') as { | ||
| 8 | + ZipFile: new () => YazlZipFile; | ||
| 9 | +}; | ||
| 10 | + | ||
| 11 | +export interface ZipFixtureEntry { | ||
| 12 | + name: string; | ||
| 13 | + content?: Buffer; | ||
| 14 | + mode?: number; | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +/** Build a zip independently of the production yauzl reader. */ | ||
| 18 | +export function makeZip(entries: ZipFixtureEntry[]): Promise<Buffer> { | ||
| 19 | + return new Promise((resolve, reject) => { | ||
| 20 | + const zip = new ZipFile(); | ||
| 21 | + const chunks: Buffer[] = []; | ||
| 22 | + zip.outputStream.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk))); | ||
| 23 | + zip.outputStream.once('error', reject); | ||
| 24 | + zip.outputStream.once('end', () => resolve(Buffer.concat(chunks))); | ||
| 25 | + for (const entry of entries) { | ||
| 26 | + zip.addBuffer(entry.content ?? Buffer.alloc(0), entry.name, { mode: entry.mode }); | ||
| 27 | + } | ||
| 28 | + zip.end(); | ||
| 29 | + }); | ||
| 30 | +} | ||
| @@ -2,16 +2,42 @@ import { describe, it, expect } from 'vitest'; | |||
| 2 | import * as fs from 'fs'; | 2 | import * as fs from 'fs'; |
| 3 | import * as os from 'os'; | 3 | import * as os from 'os'; |
| 4 | import * as path from 'path'; | 4 | import * as path from 'path'; |
| 5 | -import { extractArchive } from '../../src/archive'; | 5 | +import { cleanupArchiveCache, extractArchive } from '../../src/archive'; |
| 6 | 6 | ||
| 7 | describe('extractArchive cleanup on failure', () => { | 7 | describe('extractArchive cleanup on failure', () => { |
| 8 | it('removes the temp extract dir when extraction throws', async () => { | 8 | it('removes the temp extract dir when extraction throws', async () => { |
| 9 | - const before = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('bmc-log-')); | 9 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-cleanup-')); |
| 10 | - const bogus = path.join(os.tmpdir(), 'not-a-real.tar.gz'); | 10 | + try { |
| 11 | - fs.writeFileSync(bogus, Buffer.from('not gzip')); | 11 | + const cache = path.join(tmp, 'cache'); |
| 12 | - await expect(extractArchive(bogus)).rejects.toThrow(); | 12 | + const bogus = path.join(tmp, 'not-a-real.tar.gz'); |
| 13 | - const after = fs.readdirSync(os.tmpdir()).filter((d) => d.startsWith('bmc-log-')); | 13 | + fs.writeFileSync(bogus, Buffer.from('not gzip')); |
| 14 | - expect(after.length).toBeLessThanOrEqual(before.length); // 没有新增残留 | 14 | + await expect(extractArchive(bogus, { destinationParent: cache })).rejects.toThrow(); |
| 15 | - fs.rmSync(bogus, { force: true }); | 15 | + expect(fs.readdirSync(cache)).toEqual([]); |
| 16 | + } finally { | ||
| 17 | + fs.rmSync(tmp, { recursive: true, force: true }); | ||
| 18 | + } | ||
| 19 | + }); | ||
| 20 | + | ||
| 21 | + it('removes expired and excess cache entries inside an isolated cache root', () => { | ||
| 22 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-cache-cleanup-')); | ||
| 23 | + try { | ||
| 24 | + const old = path.join(tmp, 'bmc-log-old'); | ||
| 25 | + const middle = path.join(tmp, 'bmc-log-middle'); | ||
| 26 | + const newest = path.join(tmp, 'bmc-log-newest'); | ||
| 27 | + fs.mkdirSync(old); | ||
| 28 | + fs.mkdirSync(middle); | ||
| 29 | + fs.mkdirSync(newest); | ||
| 30 | + fs.utimesSync(old, new Date(1_000), new Date(1_000)); | ||
| 31 | + fs.utimesSync(middle, new Date(9_000), new Date(9_000)); | ||
| 32 | + fs.utimesSync(newest, new Date(10_000), new Date(10_000)); | ||
| 33 | + | ||
| 34 | + cleanupArchiveCache(tmp, { now: 10_000, maxAgeMs: 5_000, maxEntries: 1 }); | ||
| 35 | + | ||
| 36 | + expect(fs.existsSync(old)).toBe(false); | ||
| 37 | + expect(fs.existsSync(middle)).toBe(false); | ||
| 38 | + expect(fs.existsSync(newest)).toBe(true); | ||
| 39 | + } finally { | ||
| 40 | + fs.rmSync(tmp, { recursive: true, force: true }); | ||
| 41 | + } | ||
| 16 | }); | 42 | }); |
| 17 | }); | 43 | }); |
| @@ -1,83 +1,25 @@ | |||
| 1 | -/** | 1 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 2 | - * Tests that extractArchive throws platform-aware messages when both `tar` and | 2 | +import * as fs from 'fs'; |
| 3 | - * `unzip` are unavailable (i.e. the both-tools-failed error path in extractZip). | 3 | +import * as os from 'os'; |
| 4 | - */ | ||
| 5 | -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| 6 | import * as path from 'path'; | 4 | import * as path from 'path'; |
| 7 | - | ||
| 8 | -// Mock child_process BEFORE importing archive so promisify(execFile) captures | ||
| 9 | -// the mock. The archive module calls execFileAsync (promisify(execFile)) at | ||
| 10 | -// module load time, so the mock must be hoisted. | ||
| 11 | -vi.mock('child_process', async () => { | ||
| 12 | - const actual = await vi.importActual<typeof import('child_process')>('child_process'); | ||
| 13 | - return { | ||
| 14 | - ...actual, | ||
| 15 | - execFile: vi.fn(), | ||
| 16 | - }; | ||
| 17 | -}); | ||
| 18 | - | ||
| 19 | -// Also mock the pure-JS tar parsers so they don't fail when we pass a fake path. | ||
| 20 | -vi.mock('../../src/backend/parsers/tar', () => ({ | ||
| 21 | - extractTarTo: vi.fn(), | ||
| 22 | - gunzipToFile: vi.fn(), | ||
| 23 | - DEFAULT_MAX_TOTAL_BYTES: 512 * 1024 * 1024, | ||
| 24 | -})); | ||
| 25 | - | ||
| 26 | -import { execFile } from 'child_process'; | ||
| 27 | import { extractArchive } from '../../src/archive'; | 5 | import { extractArchive } from '../../src/archive'; |
| 28 | 6 | ||
| 29 | -const mockedExecFile = execFile as ReturnType<typeof vi.fn>; | 7 | +describe('extractArchive .zip errors', () => { |
| 30 | - | 8 | + let tmp: string; |
| 31 | -describe('extractArchive .zip — both-tools-failed error messages', () => { | ||
| 32 | - const fakePath = path.join('/fake', 'dump.zip'); | ||
| 33 | - let originalPlatform: PropertyDescriptor | undefined; | ||
| 34 | 9 | ||
| 35 | beforeEach(() => { | 10 | beforeEach(() => { |
| 36 | - originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); | 11 | + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-zip-error-')); |
| 37 | - | ||
| 38 | - // Make both tar and unzip calls reject (simulates: tar absent / can't read zip, | ||
| 39 | - // then unzip absent). | ||
| 40 | - mockedExecFile.mockImplementation( | ||
| 41 | - ( | ||
| 42 | - _cmd: string, | ||
| 43 | - _args: string[], | ||
| 44 | - _opts: unknown, | ||
| 45 | - cb: (err: Error | null, result?: unknown) => void, | ||
| 46 | - ) => { | ||
| 47 | - cb(new Error('command not found')); | ||
| 48 | - }, | ||
| 49 | - ); | ||
| 50 | }); | 12 | }); |
| 51 | 13 | ||
| 52 | afterEach(() => { | 14 | afterEach(() => { |
| 53 | - vi.clearAllMocks(); | 15 | + fs.rmSync(tmp, { recursive: true, force: true }); |
| 54 | - // Restore the original platform descriptor. | ||
| 55 | - if (originalPlatform) { | ||
| 56 | - Object.defineProperty(process, 'platform', originalPlatform); | ||
| 57 | - } | ||
| 58 | }); | 16 | }); |
| 59 | 17 | ||
| 60 | - it('throws a Windows-specific message on win32 (mentions 手动解压 and 打开文件夹)', async () => { | 18 | + it('reports a malformed zip without requiring platform-specific CLI tools', async () => { |
| 61 | - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); | 19 | + const archivePath = path.join(tmp, 'broken.zip'); |
| 20 | + fs.writeFileSync(archivePath, Buffer.from('not a zip')); | ||
| 62 | 21 | ||
| 63 | - await expect(extractArchive(fakePath)).rejects.toThrow(/手动解压/); | 22 | + await expect(extractArchive(archivePath, { destinationParent: tmp })).rejects.toThrow(); |
| 64 | - await expect(extractArchive(fakePath)).rejects.toThrow(/打开文件夹/); | 23 | + expect(fs.readdirSync(tmp).filter((name) => name.startsWith('bmc-log-'))).toEqual([]); |
| 65 | - await expect(extractArchive(fakePath)).rejects.toThrow(/Windows 10 1803\+/); | ||
| 66 | - }); | ||
| 67 | - | ||
| 68 | - it('throws a generic Linux/macOS message on non-win32 (mentions tar 或 unzip)', async () => { | ||
| 69 | - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); | ||
| 70 | - | ||
| 71 | - await expect(extractArchive(fakePath)).rejects.toThrow(/tar 或 unzip/); | ||
| 72 | - // Should NOT contain Windows-specific phrasing | ||
| 73 | - await expect(extractArchive(fakePath)).rejects.not.toThrow(/手动解压/); | ||
| 74 | - }); | ||
| 75 | - | ||
| 76 | - it('preserves the underlying error message in both cases', async () => { | ||
| 77 | - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); | ||
| 78 | - await expect(extractArchive(fakePath)).rejects.toThrow(/command not found/); | ||
| 79 | - | ||
| 80 | - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); | ||
| 81 | - await expect(extractArchive(fakePath)).rejects.toThrow(/command not found/); | ||
| 82 | }); | 24 | }); |
| 83 | }); | 25 | }); |
| @@ -5,6 +5,7 @@ import * as path from 'path'; | |||
| 5 | import * as zlib from 'zlib'; | 5 | import * as zlib from 'zlib'; |
| 6 | import { findDumpRoot, isArchive, extractArchive } from '../../src/archive'; | 6 | import { findDumpRoot, isArchive, extractArchive } from '../../src/archive'; |
| 7 | import { makeTar } from './_tar-fixture'; | 7 | import { makeTar } from './_tar-fixture'; |
| 8 | +import { makeZip } from './_zip-fixture'; | ||
| 8 | 9 | ||
| 9 | let tmp: string; | 10 | let tmp: string; |
| 10 | 11 | ||
| @@ -55,16 +56,50 @@ describe('extractArchive', () => { | |||
| 55 | const archivePath = path.join(tmp, 'dump.tar.gz'); | 56 | const archivePath = path.join(tmp, 'dump.tar.gz'); |
| 56 | fs.writeFileSync(archivePath, zlib.gzipSync(tar)); | 57 | fs.writeFileSync(archivePath, zlib.gzipSync(tar)); |
| 57 | 58 | ||
| 58 | - const dumpRoot = await extractArchive(archivePath); | 59 | + const dumpRoot = await extractArchive(archivePath, { destinationParent: tmp }); |
| 59 | expect(path.basename(dumpRoot)).toBe('dump_info'); | 60 | expect(path.basename(dumpRoot)).toBe('dump_info'); |
| 60 | expect(fs.readFileSync(path.join(dumpRoot, 'AppDump', 'marker.txt'), 'utf-8')).toBe('present'); | 61 | expect(fs.readFileSync(path.join(dumpRoot, 'AppDump', 'marker.txt'), 'utf-8')).toBe('present'); |
| 61 | - fs.rmSync(path.dirname(dumpRoot), { recursive: true, force: true }); // clean the os.tmpdir extract | 62 | + }); |
| 63 | + | ||
| 64 | + it('extracts a .zip without system tar/unzip and resolves the dump root', async () => { | ||
| 65 | + const archivePath = path.join(tmp, 'dump.zip'); | ||
| 66 | + fs.writeFileSync( | ||
| 67 | + archivePath, | ||
| 68 | + await makeZip([ | ||
| 69 | + { name: 'dump_info/AppDump/marker.txt', content: Buffer.from('present') }, | ||
| 70 | + { name: 'dump_info/LogDump/app.log', content: Buffer.from('hello') }, | ||
| 71 | + ]), | ||
| 72 | + ); | ||
| 73 | + | ||
| 74 | + const dumpRoot = await extractArchive(archivePath, { destinationParent: tmp }); | ||
| 75 | + expect(path.basename(dumpRoot)).toBe('dump_info'); | ||
| 76 | + expect(fs.readFileSync(path.join(dumpRoot, 'AppDump', 'marker.txt'), 'utf-8')).toBe('present'); | ||
| 77 | + }); | ||
| 78 | + | ||
| 79 | + it('rejects zip symlinks instead of materialising them', async () => { | ||
| 80 | + const archivePath = path.join(tmp, 'symlink.zip'); | ||
| 81 | + fs.writeFileSync( | ||
| 82 | + archivePath, | ||
| 83 | + await makeZip([ | ||
| 84 | + { | ||
| 85 | + name: 'dump_info/AppDump/link', | ||
| 86 | + content: Buffer.from('../../outside'), | ||
| 87 | + mode: 0o120777, | ||
| 88 | + }, | ||
| 89 | + ]), | ||
| 90 | + ); | ||
| 91 | + | ||
| 92 | + await expect(extractArchive(archivePath, { destinationParent: tmp })).rejects.toThrow( | ||
| 93 | + /符号链接/, | ||
| 94 | + ); | ||
| 62 | }); | 95 | }); |
| 63 | 96 | ||
| 64 | it('throws on an archive without an AppDump directory', async () => { | 97 | it('throws on an archive without an AppDump directory', async () => { |
| 65 | const tar = makeTar([{ name: 'random/file.txt', content: Buffer.from('x') }]); | 98 | const tar = makeTar([{ name: 'random/file.txt', content: Buffer.from('x') }]); |
| 66 | const archivePath = path.join(tmp, 'no-appdump.tar.gz'); | 99 | const archivePath = path.join(tmp, 'no-appdump.tar.gz'); |
| 67 | fs.writeFileSync(archivePath, zlib.gzipSync(tar)); | 100 | fs.writeFileSync(archivePath, zlib.gzipSync(tar)); |
| 68 | - await expect(extractArchive(archivePath)).rejects.toThrow(/未找到 AppDump/); | 101 | + await expect(extractArchive(archivePath, { destinationParent: tmp })).rejects.toThrow( |
| 102 | + /未找到 AppDump/, | ||
| 103 | + ); | ||
| 69 | }); | 104 | }); |
| 70 | }); | 105 | }); |
| @@ -0,0 +1,26 @@ | |||
| 1 | +import { describe, expect, it } from 'vitest'; | ||
| 2 | +import * as fs from 'fs'; | ||
| 3 | +import * as path from 'path'; | ||
| 4 | + | ||
| 5 | +describe('extension remote-host contract', () => { | ||
| 6 | + it('runs beside workspace files instead of in the local UI host', () => { | ||
| 7 | + const manifest = JSON.parse( | ||
| 8 | + fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8'), | ||
| 9 | + ) as { | ||
| 10 | + extensionKind?: string[]; | ||
| 11 | + capabilities?: { virtualWorkspaces?: { supported?: boolean } }; | ||
| 12 | + dependencies?: Record<string, string>; | ||
| 13 | + }; | ||
| 14 | + | ||
| 15 | + expect(manifest.extensionKind).toEqual(['workspace']); | ||
| 16 | + expect(manifest.capabilities?.virtualWorkspaces?.supported).toBe(false); | ||
| 17 | + expect(manifest.dependencies?.yauzl).toBeTruthy(); | ||
| 18 | + }); | ||
| 19 | + | ||
| 20 | + it('ships the pure-JS ZIP reader and its runtime dependency in the VSIX', () => { | ||
| 21 | + const vscodeIgnore = fs.readFileSync(path.resolve(__dirname, '../../.vscodeignore'), 'utf-8'); | ||
| 22 | + | ||
| 23 | + expect(vscodeIgnore).toContain('!node_modules/yauzl/**'); | ||
| 24 | + expect(vscodeIgnore).toContain('!node_modules/pend/**'); | ||
| 25 | + }); | ||
| 26 | +}); | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | import { describe, it, expect } from 'vitest'; | 1 | import { describe, it, expect } from 'vitest'; |
| 2 | -import { bucketOf, normalizeHistory } from '../../src/recentDumpsModel'; | 2 | +import { bucketOf, historyKey, normalizeHistory } from '../../src/recentDumpsModel'; |
| 3 | 3 | ||
| 4 | const DAY = 86_400_000; | 4 | const DAY = 86_400_000; |
| 5 | 5 | ||
| @@ -43,6 +43,26 @@ describe('normalizeHistory', () => { | |||
| 43 | expect(normalizeHistory([entry])).toEqual([entry]); | 43 | expect(normalizeHistory([entry])).toEqual([entry]); |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | + it('preserves URI-aware v2 fields and ignores invalid optional values', () => { | ||
| 47 | + const entry = { | ||
| 48 | + path: '/tmp/cache/dump_info', | ||
| 49 | + uri: 'vscode-remote://wsl+Ubuntu/tmp/cache/dump_info', | ||
| 50 | + sourceUri: 'vscode-remote://wsl+Ubuntu/home/user/dump.zip', | ||
| 51 | + kind: 'archive', | ||
| 52 | + label: 'dump.zip', | ||
| 53 | + openedAt: 1234, | ||
| 54 | + }; | ||
| 55 | + expect(normalizeHistory([entry])).toEqual([entry]); | ||
| 56 | + expect(normalizeHistory([{ ...entry, kind: 'unknown', uri: 42 }])).toEqual([ | ||
| 57 | + { | ||
| 58 | + path: entry.path, | ||
| 59 | + sourceUri: entry.sourceUri, | ||
| 60 | + label: entry.label, | ||
| 61 | + openedAt: entry.openedAt, | ||
| 62 | + }, | ||
| 63 | + ]); | ||
| 64 | + }); | ||
| 65 | + | ||
| 46 | it('fills a missing label from the path and a missing openedAt with 0', () => { | 66 | it('fills a missing label from the path and a missing openedAt with 0', () => { |
| 47 | expect(normalizeHistory([{ path: '/a/b/dump.zip' }])).toEqual([ | 67 | expect(normalizeHistory([{ path: '/a/b/dump.zip' }])).toEqual([ |
| 48 | { path: '/a/b/dump.zip', label: 'dump.zip', openedAt: 0 }, | 68 | { path: '/a/b/dump.zip', label: 'dump.zip', openedAt: 0 }, |
| @@ -62,3 +82,16 @@ describe('normalizeHistory', () => { | |||
| 62 | expect(normalizeHistory([{ path: '/y', label: 'b', openedAt: Infinity }])[0].openedAt).toBe(0); | 82 | expect(normalizeHistory([{ path: '/y', label: 'b', openedAt: Infinity }])[0].openedAt).toBe(0); |
| 63 | }); | 83 | }); |
| 64 | }); | 84 | }); |
| 85 | + | ||
| 86 | +describe('historyKey', () => { | ||
| 87 | + it('deduplicates archives by source URI even when their cache path changes', () => { | ||
| 88 | + const sourceUri = 'vscode-remote://wsl+Ubuntu/home/user/dump.tar.gz'; | ||
| 89 | + expect(historyKey({ path: '/tmp/a', uri: 'file:///tmp/a', sourceUri })).toBe(sourceUri); | ||
| 90 | + expect(historyKey({ path: '/tmp/b', uri: 'file:///tmp/b', sourceUri })).toBe(sourceUri); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + it('falls back to resource URI and then native path', () => { | ||
| 94 | + expect(historyKey({ path: '/tmp/a', uri: 'file:///tmp/a' })).toBe('file:///tmp/a'); | ||
| 95 | + expect(historyKey({ path: '/tmp/a' })).toBe('/tmp/a'); | ||
| 96 | + }); | ||
| 97 | +}); | ||