Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion dsh-hotplug-hub/lib/core/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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') }
Expand Down
25 changes: 23 additions & 2 deletions dsh-hotplug-hub/lib/core/run-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions dsh-hotplug-hub/lib/core/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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'))
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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(),
}
}
9 changes: 6 additions & 3 deletions dsh-hotplug-hub/test/gateway.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
54 changes: 54 additions & 0 deletions dsh-hotplug-hub/test/run-cli-windows.test.mjs
Original file line number Diff line number Diff line change
@@ -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+/)
})
})
28 changes: 28 additions & 0 deletions dsh-hotplug-hub/test/status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 6 additions & 22 deletions release/src/Main.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Math.max(A.length,B.length);i++){var x=parseInt(A[i]||'0',10),y=parseInt(B[i]||'0',10);if(x!==y)return x-y;}return 0;};" +
// semver 比较(数值段,pre/build 后缀整版本剔除,与 PatchContract.CompareVersions 语义一致):latest > 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<Math.max(A.length,B.length);i++){var x=parseInt(A[i]||'0',10)||0,y=parseInt(B[i]||'0',10)||0;if(x!==y)return x-y;}return 0;};" +
"for(var i=0;i<r.length;i++){" +
"if(r[i].name==='Node.js'){r[i].val=window.__nativeSelfCheck.node||'未检测到';r[i].text=window.__nativeSelfCheck.node?'已检测':'未安装';r[i].status=window.__nativeSelfCheck.node?'ok':'err';}" +
"if(r[i].name==='pnpm'){r[i].val=window.__nativeSelfCheck.pnpm||'未检测到';r[i].text=window.__nativeSelfCheck.pnpm?'已检测':'未安装';r[i].status=window.__nativeSelfCheck.pnpm?'ok':'err';}" +
Expand Down Expand Up @@ -2222,30 +2222,14 @@ private static string NormalizeVersion(string v)
return s.Length > 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,更新提示永远不会触发
Expand Down
48 changes: 48 additions & 0 deletions release/src/PatchContract.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,5 +318,53 @@ public static void ApplyOwnerOnlyAcl(string path)
}
catch { /* 尽力而为:ACL 设置失败不影响主流程 */ }
}

// ---------- 版本比较契约(CONTRACT.md §7:自检/更新检测单一真源) ----------

/// <summary>版本比较:先剥离前导 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 后再比较。</summary>
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;
}

/// <summary>candidate 是否严格大于 current(用于「发现新版本」判定)。
/// 任一为空 → false(与旧 IsNewerVersion 的空值守卫语义一致)。</summary>
public static bool IsNewerVersion(string candidate, string current)
{
if (string.IsNullOrEmpty(candidate) || string.IsNullOrEmpty(current)) return false;
return CompareVersions(candidate, current) > 0;
}

/// <summary>提取核心版本段:trim + 去前导 v/V + 去 pre(-)/build(+) 后缀 + 按 '.' 切分。</summary>
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('.');
}

/// <summary>第 i 段数值;缺失或非数字按 0(与旧 IsNewerVersion 的 int.TryParse→0 语义一致)。</summary>
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;
}
}
}
18 changes: 18 additions & 0 deletions release/tests/PatchContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 + " ==");
Expand Down
Loading