Open
feat: 支持 Remote-WSL/SSH 远端日志解析 + 纯 JS zip 解压(yauzl),版本升级至 0.0.3 #23
feat: 支持 Remote-WSL/SSH 远端日志解析 + 纯 JS zip 解压(yauzl),版本升级至 0.0.3 #23
Open
liruichenAdd on Aug 10
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# Artifacts24# Artifacts
25node_modules/**25node_modules/**
26+!node_modules/yauzl/**
27+!node_modules/pend/**
26**/*.vsix28**/*.vsix
27.DS_Store29.DS_Store
28dump_info/**30dump_info/**
MREADME.md+48-34
@@ -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```bash35```bash
25npm install36npm install
26-npm run compile # 编译 TypeScript 到 out/,并把前端资源装配到 webview/37+npm run build # TypeScript + Vue Webview
27-npm run package # 打包 VSIX38+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-```bash47+### Remote-WSL / SSH
34-"/Applications/openUBMC Studio.app/Contents/Resources/app/bin/studio" \48+ 
35- --install-extension bmcstudio-log-reviewer-<version>.vsix49+日志插件必须安装在当前远端环境中。在已连接 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 @@
1import * as fs from 'fs';1import * as fs from 'fs';
2import * as os from 'os';2import * as os from 'os';
3import * as path from 'path';3import * 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. */
11export const ARCHIVE_EXTS = ['.tar.gz', '.tgz', '.tar', '.zip'];15export 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 on70+ * 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 */
71async function extractZip(archivePath: string, extractDir: string): Promise<void> {75async function extractZip(archivePath: string, extractDir: string): Promise<void> {
72- // Probe whether tar can READ this zip (GNU tar can't); only the listing step76+ const zipfile = await yauzl.openPromise(archivePath, {
73- // decides the tool. Once a tool is chosen it both lists and extracts, and an77+ decodeStrings: true,
74- // EXTRACTION failure propagates instead of falling through to the other tool78+ 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 reader167+ * 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 it168+ * `tar`/`unzip` CLI dependency, so extraction
113 * behaves identically on every platform and is immune to shell injection and169 * 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 are170 * 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 @@
1import * as vscode from 'vscode';1import * as vscode from 'vscode';
2import * as fs from 'fs';2import * as fs from 'fs';
3import * as path from 'path';3import * 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';
6import { openV2Panel } from './host/commands';13import { openV2Panel } from './host/commands';
7import { setMcpContext, registerMcpTools } from './mcp/register';14import { setMcpContext, registerMcpTools } from './mcp/register';
15+import {
16+ defaultDialogUri,
17+ extensionHostSummary,
18+ toHostFsPath,
19+ uriForHostPath,
20+} from './resourceUri';
8 21 
9let recentProvider: RecentDumpsProvider | undefined;22let 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 
11export function activate(context: vscode.ExtensionContext): {35export 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 a145+ 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 
107async function pickDumpDir(173async 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 deleted176+ 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 
141export function deactivate(): void {}241export function deactivate(): void {}
@@ -5,9 +5,9 @@ import { isApiError } from '../shared/api-error';
5import type { GenericInfoResponse } from '../shared/types/overview';5import 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
4import { openV2Panel } from '../host/commands';4import { openV2Panel } from '../host/commands';
5import { isArchive } from '../archive';5import { isArchive } from '../archive';
6import { recordDump, listRecentDumps } from '../recentDumpsProvider';6import { recordDump, listRecentDumps } from '../recentDumpsProvider';
7+import { uriForHostPath } from '../resourceUri';
7 8 
8// host 调 registerMcpTools 时不传 ExtensionContext;activate() 通过 setMcpContext 注入。9// host 调 registerMcpTools 时不传 ExtensionContext;activate() 通过 setMcpContext 注入。
9let extensionContext: vscode.ExtensionContext | undefined;10let 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 
11export const MAX_HISTORY = 30;11export 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. */
14export interface DumpHistoryEntry {16export 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 
13function readHistory(context: vscode.ExtensionContext): DumpHistoryEntry[] {16function 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 children110 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';
2import * as fs from 'fs';2import * as fs from 'fs';
3import * as os from 'os';3import * as os from 'os';
4import * as path from 'path';4import * as path from 'path';
5-import { extractArchive } from '../../src/archive';5+import { cleanupArchiveCache, extractArchive } from '../../src/archive';
6 6 
7describe('extractArchive cleanup on failure', () => {7describe('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` and2+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';
6import * as path from 'path';4import * 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';
27import { extractArchive } from '../../src/archive';5import { 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';
5import * as zlib from 'zlib';5import * as zlib from 'zlib';
6import { findDumpRoot, isArchive, extractArchive } from '../../src/archive';6import { findDumpRoot, isArchive, extractArchive } from '../../src/archive';
7import { makeTar } from './_tar-fixture';7import { makeTar } from './_tar-fixture';
8+import { makeZip } from './_zip-fixture';
8 9 
9let tmp: string;10let 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 extract62+ });
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 @@
1import { describe, it, expect } from 'vitest';1import { describe, it, expect } from 'vitest';
2-import { bucketOf, normalizeHistory } from '../../src/recentDumpsModel';2+import { bucketOf, historyKey, normalizeHistory } from '../../src/recentDumpsModel';
3 3 
4const DAY = 86_400_000;4const 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+});