From 59b08faef2b2adbadb5d613c0918823103da0972 Mon Sep 17 00:00:00 2001 From: YisfL <128236527+YisfL@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:07:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E6=A3=80=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=AE=A1=E8=AE=A1=E4=B8=8E=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=EF=BC=88Windows=20pnpm=20=E6=8E=A2=E6=B5=8B=20/=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=AF=94=E8=BE=83=E5=A5=91=E7=BA=A6=20/=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=A5=91=E7=BA=A6=E7=BB=9F=E4=B8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runCli:Windows 下 .cmd/.bat/裸命令经 ComSpec(cmd.exe /d /c)包装(对齐 launcher C6)—— 修复 npm 全局安装的 pnpm.cmd 无法被 spawn(shell:false) 直接启动(EINVAL/ENOENT), 导致 checkAsync 自检「pnpm」恒误报缺失、ensureNpm/unmountPack/rollbackMount 的 pnpm add/remove 全失效 - statusSync:activatedAt 改为反向遍历取「最近一次」激活(修复激活→卸载→再激活后返回最早时间戳的陈旧数据) - statusSync:补 memoryDir 字段(与 checkAsync 契约统一,单一真源 MEMORY_DIR) - 版本比较契约统一:新增 PatchContract.CompareVersions/IsNewerVersion(pre/build 整版本剥离, 修复 IsNewerVersion 按「段」剥离 '-' 导致 1.0.0-alpha.1 被误判 > 1.0.0),Main.cs 委托, 注入页面 JS nv 同步 split(/[-+]/) 与 parseInt||0 - 新增回归测试:run-cli-windows.test.mjs(真实 pnpm.cmd spawn / checkAsync 探测 / .exe 直连)、 status 3 项、C# CompareVersions/IsNewerVersion 15 项 本地全绿:hotplug 182 / shared-core 178 / memory-hub 61 / C# 84 / qa9 17 / vendor 同步一致 --- dsh-hotplug-hub/lib/core/paths.js | 4 +- dsh-hotplug-hub/lib/core/run-cli.js | 25 ++++++++- dsh-hotplug-hub/lib/core/status.js | 22 ++++++-- dsh-hotplug-hub/test/gateway.test.mjs | 9 ++-- dsh-hotplug-hub/test/run-cli-windows.test.mjs | 54 +++++++++++++++++++ dsh-hotplug-hub/test/status.test.mjs | 28 ++++++++++ release/src/Main.cs | 28 +++------- release/src/PatchContract.cs | 48 +++++++++++++++++ release/tests/PatchContractTests.cs | 18 +++++++ 9 files changed, 205 insertions(+), 31 deletions(-) create mode 100644 dsh-hotplug-hub/test/run-cli-windows.test.mjs diff --git a/dsh-hotplug-hub/lib/core/paths.js b/dsh-hotplug-hub/lib/core/paths.js index cc3916d..7ee189c 100644 --- a/dsh-hotplug-hub/lib/core/paths.js +++ b/dsh-hotplug-hub/lib/core/paths.js @@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' // v5:常量单一真源 = vendor-shared(shared-core 字节副本) -import { GITHUB_MIRRORS as SHARED_GITHUB_MIRRORS, resolveDshRoot } from '../../vendor-shared/index.mjs' +import { GITHUB_MIRRORS as SHARED_GITHUB_MIRRORS, MEMORY_DIR, resolveDshRoot } from '../../vendor-shared/index.mjs' export const VERSION = (() => { try { @@ -62,6 +62,8 @@ export const MARKET_PACK_CANDIDATES = ['hotpack.json', '.dshpack.json', 'dshpack export function homeDir() { return resolveDshRoot(process.env).dshRoot } +/** 全局记忆中枢根目录(与 dsh-memory-hub 的 MEMORY_DIR 单一真源一致)。 */ +export function memoryDir() { return join(homeDir(), MEMORY_DIR) } export function hotplugRoot() { return join(homeDir(), 'hotplug-hub') } export function packsDir() { return join(hotplugRoot(), 'packs') } export function storeRoot() { return join(homeDir(), 'hotplug-store') } diff --git a/dsh-hotplug-hub/lib/core/run-cli.js b/dsh-hotplug-hub/lib/core/run-cli.js index 255fc65..6d16179 100644 --- a/dsh-hotplug-hub/lib/core/run-cli.js +++ b/dsh-hotplug-hub/lib/core/run-cli.js @@ -6,12 +6,33 @@ * SSL_CERT_FILE/SSL_CERT_DIR——TLS 校验不可被静默关闭、Node 行为不可被注入)。 */ import { spawn } from 'node:child_process' +import { extname } from 'node:path' import { sanitizeChildEnv } from '../../vendor-shared/index.mjs' -import { OUTPUT_CAP, profileDir } from './paths.js' +import { IS_WIN, OUTPUT_CAP, profileDir } from './paths.js' + +/** + * C6 修复(与 launcher infra/launch.js wrapCmdScript 同源):Windows 下 `.cmd` / `.bat` + * / 裸命令(如 `pnpm`,npm 全局安装只生成 `pnpm.cmd` 而非 `pnpm.exe`)不能被 + * CreateProcess 直接启动(Node spawn shell:false 抛 EINVAL),必须经 ComSpec + * (cmd.exe /d /c)包装。ComSpec 用绝对路径,避免 PATH 被隔离(如测试隔离环境) + * 时 `cmd.exe` 自身 ENOENT。 + * 注:curl.exe / tar.exe 已带 `.exe` 扩展名,不命中本分支,仍走直接 spawn。 + */ +function isWindowsShellCommand(command) { + if (!IS_WIN) return false + const ext = extname(String(command ?? '')).toLowerCase() + return ext === '' || ext === '.cmd' || ext === '.bat' +} export function runCli(command, args, timeoutMs, options = {}) { return new Promise((resolve) => { - const child = spawn(command, args, { + let bin = command + let argv = args + if (isWindowsShellCommand(command)) { + bin = process.env.ComSpec || 'cmd.exe' + argv = ['/d', '/c', command, ...args] + } + const child = spawn(bin, argv, { cwd: options.cwd ?? profileDir(), env: sanitizeChildEnv(process.env), windowsHide: true, diff --git a/dsh-hotplug-hub/lib/core/status.js b/dsh-hotplug-hub/lib/core/status.js index 735525a..0a7ad04 100644 --- a/dsh-hotplug-hub/lib/core/status.js +++ b/dsh-hotplug-hub/lib/core/status.js @@ -3,13 +3,28 @@ */ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { VERSION, homeDir, packsDir, patchPath, profileDir, profileName, storeRoot } from './paths.js' +import { VERSION, homeDir, memoryDir, packsDir, patchPath, profileDir, profileName, storeRoot } from './paths.js' import { readJson, readPackManifest, listPackIds, readState, writeJsonSafe } from './state.js' import { runCli } from './run-cli.js' import { parseHotpack } from './hotpack.js' import { installedVersion, npmModuleDir, storeDirOf } from './ensure.js' import { findPatchBlock } from '../../vendor-shared/index.mjs' +/** 取某包「最近一次」activate 事件的时间戳(反向遍历,避免 find 命中历史里最早一次激活)。 + * 审计修复:此前 `state.history?.find?.(...)?.at` 只取第一次 activate——包经历 + * 激活→卸载→再激活后,activatedAt 返回的是最早那次,属陈旧数据;且 `?.at` 与 + * Array.prototype.at 同名易误读。 */ +function lastActivationAt(state, id) { + const history = Array.isArray(state?.history) ? state.history : [] + for (let i = history.length - 1; i >= 0; i--) { + const item = history[i] + if (item && item.packId === id && item.event === 'activate') { + return typeof item.at === 'string' ? item.at : null + } + } + return null +} + export function statusSync() { const state = readState() const packs = [] @@ -23,7 +38,7 @@ export function statusSync() { description: manifest.description ?? '', tags: manifest.tags ?? [], active: state.activePack === id, - activatedAt: state.history?.find?.((item) => item.packId === id && item.event === 'activate')?.at ?? null, + activatedAt: lastActivationAt(state, id), plugins: (manifest.plugins ?? []).map((entry) => { const dir = storeDirOf(entry) const present = existsSync(join(dir, 'package.json')) @@ -57,6 +72,7 @@ export function statusSync() { activePatchOk: state.activePack ? findPatchBlock(patchText, 'hotplug', state.activePack).found : true, packs, store: { dir: storeRoot(), entries: storeEntries }, + memoryDir: memoryDir(), } } @@ -155,6 +171,6 @@ export async function checkAsync() { activePack: state.activePack, packCount: status.packs.length, storeCount: status.store.entries.length, - memoryDir: join(homeDir(), 'memory-hub'), + memoryDir: memoryDir(), } } diff --git a/dsh-hotplug-hub/test/gateway.test.mjs b/dsh-hotplug-hub/test/gateway.test.mjs index a8e931e..fcdd0ac 100644 --- a/dsh-hotplug-hub/test/gateway.test.mjs +++ b/dsh-hotplug-hub/test/gateway.test.mjs @@ -69,9 +69,12 @@ describe('runCli(H-6 / R-v5-9)', () => { expect(seen.s).toBeUndefined() }) - it('命令不存在 → {code:null, signal:"error"},不抛异常', async () => { + it('命令不存在 → 返回失败结果,不抛异常', async () => { const r = await runCli('definitely-not-a-command-xyz', [], 2000, { cwd: iso.profile }) - expect(r.code).toBeNull() - expect(r.signal).toBe('error') + // 失败形态因平台而异:POSIX 直连 spawn → error 事件(code null / signal 'error'); + // Windows 经 cmd.exe /c 包装 → cmd 退出码 1。核心不变量:不抛异常 + 非成功码。 + expect(r.code).not.toBe(0) + expect(typeof r.stdout).toBe('string') + expect(typeof r.stderr).toBe('string') }) }) diff --git a/dsh-hotplug-hub/test/run-cli-windows.test.mjs b/dsh-hotplug-hub/test/run-cli-windows.test.mjs new file mode 100644 index 0000000..c37b881 --- /dev/null +++ b/dsh-hotplug-hub/test/run-cli-windows.test.mjs @@ -0,0 +1,54 @@ +// test/run-cli-windows.test.mjs — runCli 对 Windows .cmd/.bat/裸命令的 cmd.exe 包装(C6 同源修复) +// +// 根因(先红后修):npm 全局安装的 pnpm 在 Windows 上只生成 `pnpm.cmd`(非 pnpm.exe), +// 而 runCli 此前用 spawn(command, args, {shell:false}) 直连——Node ≥18.20/20.12/21.7 起 +// (CVE-2024-27980 修复)对 .cmd/.bat 直接 CreateProcess 抛 EINVAL,导致: +// - checkAsync 的 pnpm --version 永远失败 → 自检「pnpm」误报未安装; +// - ensureNpm / unmountPack / rollbackMount 的 pnpm add/remove 全部失效。 +// launcher infra/launch.js 已有 C6 结论(cmd.exe /d /c 包装);本测试证明 hotplug runCli +// 同步对齐该结论。 +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { runCli } from '../lib/core/run-cli.js' +import { checkAsync } from '../lib/core/status.js' +import { applyIsolatedEnv, isolatedDsh } from './helpers.mjs' + +let restoreEnv = null +let iso = null + +beforeEach(() => { + iso = isolatedDsh() + restoreEnv = applyIsolatedEnv(iso.dshHome) +}) +afterEach(() => { if (restoreEnv) restoreEnv(); if (iso) iso.cleanup() }) + +/** 在隔离 PATH 放置真实 pnpm.cmd(echo 版本),模拟 npm install -g pnpm 的标准产物形态。 */ +function writePnpmCmd(version = '7.7.7') { + writeFileSync(join(iso.dshHome, 'pnpm.cmd'), `@echo off\r\necho ${version}\r\n`) +} + +describe.skipIf(process.platform !== 'win32')('runCli Windows .cmd 包装(C6 回归)', () => { + it('裸命令 pnpm(实为 pnpm.cmd)经 cmd.exe /d /c 包装可成功执行', async () => { + writePnpmCmd() + const r = await runCli('pnpm', ['--version'], 5000) + expect(r.code).toBe(0) + expect(r.signal).toBeNull() + expect((r.stdout || '').trim()).toBe('7.7.7') + }) + + it('checkAsync 在 pnpm 以 .cmd 形态安装时仍能探测到版本(自检不误报缺失)', async () => { + writePnpmCmd() + const r = await checkAsync() + expect(r.pnpmVersion).toBe('7.7.7') + }) + + it('.exe 形态(pnpm.exe)仍正常直连 spawn,不因包装逻辑失效', async () => { + // pnpm.exe = node.exe 副本:`node --version` 输出 node 版本(v 前缀),验证直连路径 + const { copyFileSync } = await import('node:fs') + copyFileSync(process.execPath, join(iso.dshHome, 'pnpm.exe')) + const r = await runCli('pnpm', ['--version'], 5000) + expect(r.code).toBe(0) + expect((r.stdout || '').trim()).toMatch(/^v\d+\.\d+\.\d+/) + }) +}) diff --git a/dsh-hotplug-hub/test/status.test.mjs b/dsh-hotplug-hub/test/status.test.mjs index 3ea8761..1f9b9cc 100644 --- a/dsh-hotplug-hub/test/status.test.mjs +++ b/dsh-hotplug-hub/test/status.test.mjs @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { join } from 'node:path' import { copyFileSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs' import { statusSync, importPackSync, previewPack, checkAsync } from '../lib/core/status.js' +import { readState, writeState } from '../lib/core/state.js' import { applyIsolatedEnv, isolatedDsh, samplePack } from './helpers.mjs' let restoreEnv = null @@ -53,6 +54,33 @@ describe('statusSync', () => { expect(plugins[0].cached).toBe(false) // npm 未装 expect(plugins[1].cached).toBe(false) // path 不存在 }) + + it('activatedAt 取最近一次激活(激活→卸载→再激活,非最早那次)', () => { + importPackSync(JSON.stringify(samplePack())) + writeState({ + ...readState(), + activePack: 'pack.test', + history: [ + { event: 'activate', packId: 'pack.test', at: '2020-01-01T00:00:00.000Z' }, + { event: 'deactivate', packId: 'pack.test', at: '2020-01-02T00:00:00.000Z' }, + { event: 'activate', packId: 'pack.test', at: '2020-01-03T00:00:00.000Z' }, + ], + }) + const s = statusSync() + expect(s.packs[0].active).toBe(true) + expect(s.packs[0].activatedAt).toBe('2020-01-03T00:00:00.000Z') + }) + + it('无激活历史时 activatedAt 为 null(不抛错)', () => { + importPackSync(JSON.stringify(samplePack())) + const s = statusSync() + expect(s.packs[0].activatedAt).toBeNull() + }) + + it('statusSync 含 memoryDir(与 checkAsync 契约统一,单一真源 MEMORY_DIR)', () => { + const s = statusSync() + expect(s.memoryDir).toContain('memory-hub') + }) }) describe('previewPack', () => { diff --git a/release/src/Main.cs b/release/src/Main.cs index c16636f..ceb7edf 100644 --- a/release/src/Main.cs +++ b/release/src/Main.cs @@ -1548,8 +1548,8 @@ private static string BuildNativeSelfCheckScript() "if(window.__nativeSelfCheck.dshVersion){state.dshVersion=window.__nativeSelfCheck.dshVersion;if(window.__nativeSelfCheck.latestVersion){state.latestVersion=window.__nativeSelfCheck.latestVersion;}if(typeof renderShell==='function')renderShell();}" + "if(window.__nativeSelfCheck.panelInstalled||window.__nativeSelfCheck.panelLatest){state.panelInstalled=window.__nativeSelfCheck.panelInstalled||state.panelInstalled||null;state.panelLatest=window.__nativeSelfCheck.panelLatest||state.panelLatest||null;}" + "(function(){window.__baseGetChecks=window.__baseGetChecks||getChecks;getChecks=function(){var r=window.__baseGetChecks();" + - // semver 比较(数值段,pre 后缀剔除):latest > app 才算「可更新」——本地领先(如 0.9.8 未发布)不误报 - "var nv=function(a,b){var A=String(a||'').replace(/^v/i,'').split('-')[0].split('.'),B=String(b||'').replace(/^v/i,'').split('-')[0].split('.');for(var i=0;i app 才算「可更新」——本地领先(如 0.9.8 未发布)不误报 + "var nv=function(a,b){var A=String(a||'').replace(/^v/i,'').trim().split(/[-+]/)[0].split('.'),B=String(b||'').replace(/^v/i,'').trim().split(/[-+]/)[0].split('.');for(var i=0;i 0 && (s[0] == 'v' || s[0] == 'V') ? s.Substring(1) : s; } - // semver 语义比较(数值段逐个比对,pre 后缀(如 -pre)剥离后比较): + // semver 语义比较(数值段逐个比对,pre/build 后缀整版本剥离后比较): // 仅当 candidate(远程/最新)严格大于 current(本地)时返回 true—— // 修复「本地构建版本领先 GitHub 发布时(如 0.9.8 尚未发布)每次启动误报发现新版本」。 + // 审计修复:收敛到 PatchContract.CompareVersions(与注入页面 JS nv 语义一致; + // 旧实现按「段」剥离 '-',`1.0.0-alpha.1` 会把 `alpha` 当作第 4 段 1 误判 > `1.0.0`)。 private static bool IsNewerVersion(string candidate, string current) { - string a = NormalizeVersion(candidate); - string b = NormalizeVersion(current); - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - string[] pa = a.Split('.'); - string[] pb = b.Split('.'); - for (int i = 0; i < Math.Max(pa.Length, pb.Length); i++) - { - int x = 0, y = 0; - int.TryParse(i < pa.Length ? StripPreSuffix(pa[i]) : "0", out x); - int.TryParse(i < pb.Length ? StripPreSuffix(pb[i]) : "0", out y); - if (x != y) return x > y; - } - return false; - } - - private static string StripPreSuffix(string s) - { - int idx = s.IndexOf('-'); - return idx > 0 ? s.Substring(0, idx) : s; + return PatchContract.IsNewerVersion(candidate, current); } // 必须请求 releases/latest:请求 releases/tags/v{当前版本} 拿到的永远是自身 tag,更新提示永远不会触发 diff --git a/release/src/PatchContract.cs b/release/src/PatchContract.cs index 1359f4a..f9f66eb 100644 --- a/release/src/PatchContract.cs +++ b/release/src/PatchContract.cs @@ -318,5 +318,53 @@ public static void ApplyOwnerOnlyAcl(string path) } catch { /* 尽力而为:ACL 设置失败不影响主流程 */ } } + + // ---------- 版本比较契约(CONTRACT.md §7:自检/更新检测单一真源) ---------- + + /// 版本比较:先剥离前导 v/V 与空白,再取 '-'/'+' 之前的核心段,按 '.' + /// 切分后逐段数值比较(非数字段按 0、缺失段按 0)。返回 -1 / 0 / 1。 + /// 与 Main.cs 注入页面的 JS nv(split(/[-+]/)[0] 后逐段 parseInt||0)语义一致。 + /// 修正旧 IsNewerVersion 的 pre 后缀「逐段剥离」缺陷:`1.0.0-alpha.1` 曾被误判 + /// 大于 `1.0.0`(alpha.1 被当成第 4 段 1),现按整版本剥离 pre/build 后再比较。 + public static int CompareVersions(string a, string b) + { + string[] pa = CoreSegments(a); + string[] pb = CoreSegments(b); + int len = Math.Max(pa.Length, pb.Length); + for (int i = 0; i < len; i++) + { + int x = ParseSegment(pa, i); + int y = ParseSegment(pb, i); + if (x != y) return x > y ? 1 : -1; + } + return 0; + } + + /// candidate 是否严格大于 current(用于「发现新版本」判定)。 + /// 任一为空 → false(与旧 IsNewerVersion 的空值守卫语义一致)。 + public static bool IsNewerVersion(string candidate, string current) + { + if (string.IsNullOrEmpty(candidate) || string.IsNullOrEmpty(current)) return false; + return CompareVersions(candidate, current) > 0; + } + + /// 提取核心版本段:trim + 去前导 v/V + 去 pre(-)/build(+) 后缀 + 按 '.' 切分。 + private static string[] CoreSegments(string v) + { + if (string.IsNullOrEmpty(v)) return new string[0]; + string s = v.Trim(); + if (s.Length > 0 && (s[0] == 'v' || s[0] == 'V')) s = s.Substring(1); + int cut = s.IndexOfAny(new char[] { '-', '+' }); + if (cut >= 0) s = s.Substring(0, cut); + return s.Split('.'); + } + + /// 第 i 段数值;缺失或非数字按 0(与旧 IsNewerVersion 的 int.TryParse→0 语义一致)。 + private static int ParseSegment(string[] segments, int i) + { + if (i >= segments.Length) return 0; + int n; + return int.TryParse(segments[i], out n) ? n : 0; + } } } diff --git a/release/tests/PatchContractTests.cs b/release/tests/PatchContractTests.cs index ca3e64f..054c6a6 100644 --- a/release/tests/PatchContractTests.cs +++ b/release/tests/PatchContractTests.cs @@ -184,6 +184,24 @@ public static int Run() Check(!SafeArg("", "tag"), "拒绝空串"); Check(!SafeArg(new string('x', 257), "tag"), "拒绝超长"); + // ⑦ 版本比较契约(自检/更新检测单一真源) + Console.WriteLine("-- ⑦ CompareVersions / IsNewerVersion(自检/更新检测单一真源) --"); + Check(PatchContract.CompareVersions("1.0.0", "1.0.0") == 0, "1.0.0 == 1.0.0"); + Check(PatchContract.CompareVersions("0.9.8", "0.9.7") > 0, "0.9.8 > 0.9.7"); + Check(PatchContract.CompareVersions("0.9.7", "0.9.8") < 0, "0.9.7 < 0.9.8"); + Check(PatchContract.CompareVersions("v1.2.3", "1.2.3") == 0, "前导 v 视为相同"); + Check(PatchContract.CompareVersions("1.0.10", "1.0.9") > 0, "多位数段 10 > 9(非字符串比较)"); + Check(PatchContract.CompareVersions("1", "1.0.0") == 0, "缺失段按 0"); + Check(PatchContract.CompareVersions("1.0.0-alpha.1", "1.0.0") == 0, "pre 后缀整版本剥离(alpha.1 不再当第 4 段)"); + Check(PatchContract.CompareVersions("1.0.0+build.5", "1.0.0") == 0, "build 元数据剥离"); + Check(PatchContract.IsNewerVersion("0.9.8", "0.9.7"), "IsNewer 0.9.8>0.9.7"); + Check(!PatchContract.IsNewerVersion("1.0.0-alpha.1", "1.0.0"), "IsNewer pre 不误判 > release"); + Check(!PatchContract.IsNewerVersion("1.0.0", "1.0.0"), "IsNewer 相等 false"); + Check(!PatchContract.IsNewerVersion("0.9.7", "0.9.8"), "IsNewer 落后 false"); + Check(!PatchContract.IsNewerVersion("", "1.0.0"), "IsNewer 空 candidate false"); + Check(!PatchContract.IsNewerVersion("1.0.0", ""), "IsNewer 空 current false"); + Check(!PatchContract.IsNewerVersion(null, "1.0.0"), "IsNewer null candidate false"); + Directory.Delete(dir, true); Console.WriteLine("== 结果:PASS=" + _passes + " FAIL=" + _failures + " ==");