@mrrisega/dsh-remote 0.7.1 → 0.7.2-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 安装/配对自检 + 配对备份/恢复(2026-10-17 真实事故,node/插件半)。
4
+ *
5
+ * 事故:用户本机 `~/.dsh-remote`(bridge 运行时 + **账号配对信息/设备密钥**)被误删 →
6
+ * · bridge 无法启动、设备离线;
7
+ * · 面板状态机只显示模糊的「未运行/准备中」,用户不知道该重新安装还是该重新登录。
8
+ *
9
+ * 本文件锁死(全部在 `mkdtemp` 临时 relayDir + 假 HOME 上,**绝不在真实 ~/.dsh-remote / ~/.dsh 上做任何写测试**):
10
+ * ① 自检状态明确区分四种:全缺失 / 只缺运行时(clients/)/ 只缺配对(.dsh-config.json)/ 正常;
11
+ * ② 运行时缺失 → 复用既有 ensureRuntime 从插件自带 runtime/ 自愈补装(配对缺失必须用户登录,
12
+ * 测试断言**绝不自填任何凭据** —— 补装后 .dsh-config.json 仍然不存在);
13
+ * ③ 配对备份**双份**:`<relayDir>/backup/` + `<profile>/dsh-remote-web-data/config-backup/`
14
+ * (后者不随 relayDir 删除、也不随插件包升级消失),两处各自 600/700、各保留最近 3 份、失败静默;
15
+ * ④ 主配置缺失但**任一份**备份存在 → **不自动恢复**,状态提示「检测到历史配对备份(时间)」,
16
+ * name 带来源(`profile:<file>` / `relay:<file>`),写清「两份都在时默认优先哪份」;
17
+ * ⑤ 真机场景:**整个 relayDir 被删、profile 那份还在** → 状态仍提示可恢复,且能真的恢复回来;
18
+ * ⑥ 彻底卸载时 profile 数据目录里的第二份备份一起清掉(不把凭据副本留给"已卸载"的用户);
19
+ * ⑦ 🔒 日志/状态/open API 里**绝不出现密码明文**(只出现文件名与时间)。
20
+ */
21
+ import assert from "node:assert/strict";
22
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
23
+ import { mkdtemp, rm } from "node:fs/promises";
24
+ import http from "node:http";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import test from "node:test";
28
+
29
+ // 必须在 import 之前:模块加载时就读取这些环境变量(与 test:plugin 脚本同一套隔离值)
30
+ process.env.DSH_PROFILE_DIR = "";
31
+ process.env.DSH_REMOTE_TELEMETRY = "0";
32
+ process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
33
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 测试隔离:不做任何真实系统操作
34
+
35
+ const mod = await import("../lib/index.js");
36
+ const {
37
+ apply,
38
+ __setupStateOf: setupStateOf,
39
+ __ensureConnection: ensureConnection,
40
+ __backupConfigFile: backupConfigFile,
41
+ __listConfigBackups: listConfigBackups,
42
+ __listConfigBackupsIn: listConfigBackupsIn,
43
+ __restoreConfigBackup: restoreConfigBackup,
44
+ __configBackupDirOf: configBackupDirOf,
45
+ __pluginProfileDir: pluginProfileDir,
46
+ __uninstallSelf: uninstallSelf,
47
+ __CONFIG_BACKUP_KEEP: BACKUP_KEEP,
48
+ __CONFIG_BACKUP_DIR: BACKUP_DIR,
49
+ __PROFILE_DATA_DIR: PROFILE_DATA_DIR,
50
+ __PROFILE_BACKUP_SUBDIR: PROFILE_BACKUP_SUBDIR,
51
+ __runtimeDirPresent: runtimeDirPresent,
52
+ __pruneConfigBackups: pruneConfigBackups,
53
+ } = mod;
54
+
55
+ const PLUGIN_VERSION = JSON.parse(
56
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
57
+ ).version;
58
+
59
+ /** 假配对密码:任何日志/状态/open API 里出现这串都算泄漏。 */
60
+ const PW = "PW_SHOULD_NEVER_LEAK_9f3c7a";
61
+ const PHONE = "13800001111";
62
+ const PRIVATE_KEY = "PRIVATE_KEY_SHOULD_NOT_LEAK";
63
+
64
+ /** 临时 relayDir + 假 HOME(绝不让任何写入落到真实用户目录)。 */
65
+ async function makeEnv() {
66
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-repair-backup-"));
67
+ const fakeHome = path.join(root, "home");
68
+ const relayDir = path.join(root, "relay");
69
+ // 安全自证①:所有写操作都发生在 mkdtemp 临时目录里 —— 绝不在真实的 ~/.dsh-remote 上做写入测试
70
+ assert.ok(relayDir.startsWith(os.tmpdir()), "测试 relayDir 必须是临时目录: " + relayDir);
71
+ mkdirSync(fakeHome, { recursive: true });
72
+ mkdirSync(relayDir, { recursive: true });
73
+ const saved = {};
74
+ for (const k of ["HOME", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_BUNDLED_RUNTIME"]) saved[k] = process.env[k];
75
+ process.env.HOME = fakeHome;
76
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
77
+ return {
78
+ root, fakeHome, relayDir,
79
+ /** 第二份备份目录(profile 下的独立数据目录;随假 HOME 落在临时目录)。 */
80
+ profileBackup() { return configBackupDirOf(relayDir, "profile"); },
81
+ relayBackup() { return configBackupDirOf(relayDir, "relay"); },
82
+ async restore() {
83
+ for (const [k, v] of Object.entries(saved)) {
84
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
85
+ }
86
+ await rm(root, { recursive: true, force: true });
87
+ },
88
+ };
89
+ }
90
+
91
+ /** 写一份"配对完成"的 .dsh-config.json(内容含明文密码 → 用于断言绝不泄漏)。 */
92
+ function writePairingConfig(relayDir, extra = {}) {
93
+ const cfg = {
94
+ phone: PHONE,
95
+ password: PW,
96
+ device_id: "dev-repair-backup",
97
+ device_private_key: PRIVATE_KEY,
98
+ api_url: "http://127.0.0.1:1",
99
+ ...extra,
100
+ };
101
+ writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify(cfg, null, 2), { mode: 0o600 });
102
+ chmodSync(path.join(relayDir, ".dsh-config.json"), 0o600);
103
+ return cfg;
104
+ }
105
+
106
+ /** 造出"运行时存在"的最小现场(自检只看 clients/ 在不在;不需要可执行内容)。 */
107
+ function writeRuntimeDir(relayDir) {
108
+ mkdirSync(path.join(relayDir, "clients", "dsh-remote"), { recursive: true });
109
+ writeFileSync(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub(自检只看文件是否齐全)\n");
110
+ }
111
+
112
+ /** 造出"插件自带运行环境"(自愈补装源):ensureRuntime 会把它整份拷到 relayDir。 */
113
+ function writeBundledRuntime(dir) {
114
+ mkdirSync(path.join(dir, "clients", "dsh-remote"), { recursive: true });
115
+ writeFileSync(path.join(dir, "dsh-setup.mjs"), "// bundled runtime stub(测试用最小形态)\n");
116
+ writeFileSync(path.join(dir, "clients", "dsh-remote", "dsh-bridge.mjs"), "// bridge stub\n");
117
+ }
118
+
119
+ /** 在一处备份目录里手工放一份历史备份(模拟"上次配对成功时留下的那一份")。 */
120
+ function writeManualBackup(dir, file, cfg) {
121
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
122
+ writeFileSync(path.join(dir, file), JSON.stringify(cfg), { mode: 0o600 });
123
+ chmodSync(path.join(dir, file), 0o600);
124
+ }
125
+
126
+ const readLog = (relayDir) => {
127
+ try { return readFileSync(path.join(relayDir, ".dsh-setup-install.log"), "utf8"); } catch { return ""; }
128
+ };
129
+ const mainConfigOf = (relayDir) => JSON.parse(readFileSync(path.join(relayDir, ".dsh-config.json"), "utf8"));
130
+
131
+ // ══════════════════════════════════════════════════════════════════════════════
132
+ // ① 自检状态:四种状态必须明确、可区分
133
+ // ══════════════════════════════════════════════════════════════════════════════
134
+
135
+ test("自检·全缺失(~/.dsh-remote 被整目录删除):同时说清「运行环境缺失」与「未配对」", async () => {
136
+ const env = await makeEnv();
137
+ try {
138
+ const st = setupStateOf(env.relayDir);
139
+ assert.equal(st.kind, "all_missing", "运行时与配对都不在 → all_missing");
140
+ assert.equal(st.runtimePresent, false);
141
+ assert.equal(st.configPresent, false);
142
+ assert.match(st.text, /运行环境缺失:需要重新安装(应用内插件页重装或 npx @mrrisega\/dsh-remote@latest)/,
143
+ "必须明确告诉用户「要重新安装」,并给出两条安装路径");
144
+ assert.match(st.text, /未配对:需要在面板里登录一次(账号密码),登录后会自动重建运行环境并启动 bridge/,
145
+ "必须明确告诉用户「要登录一次」");
146
+ assert.equal(st.backup, null, "没有备份时不得给出恢复动作");
147
+ } finally { await env.restore(); }
148
+ });
149
+
150
+ test("自检·只缺运行时(clients/ 不在,配对还在):只说重新安装,不误导去登录", async () => {
151
+ const env = await makeEnv();
152
+ try {
153
+ writePairingConfig(env.relayDir);
154
+ const st = setupStateOf(env.relayDir);
155
+ assert.equal(st.kind, "runtime_missing");
156
+ assert.equal(st.runtimePresent, false);
157
+ assert.equal(st.configPresent, true);
158
+ assert.match(st.text, /运行环境缺失:需要重新安装/);
159
+ assert.ok(!/未配对/.test(st.text), "配对还在时不得提示登录(会把人引到错误动作上)");
160
+ assert.match(st.detail, /自动补装|重新安装/, "detail 要说明插件会先尝试自动补装");
161
+ } finally { await env.restore(); }
162
+ });
163
+
164
+ test("自检·只缺配对(.dsh-config.json 不在,运行时还在):只说登录,不误导去重装", async () => {
165
+ const env = await makeEnv();
166
+ try {
167
+ writeRuntimeDir(env.relayDir);
168
+ const st = setupStateOf(env.relayDir);
169
+ assert.equal(st.kind, "pairing_missing");
170
+ assert.equal(st.runtimePresent, true);
171
+ assert.equal(st.configPresent, false);
172
+ assert.match(st.text, /未配对:需要在面板里登录一次(账号密码)/);
173
+ assert.ok(!/运行环境缺失/.test(st.text), "运行时还在时不得提示重装");
174
+ assert.match(st.detail, /绝不自动填入任何凭据/, "安全边界要写进状态说明");
175
+ } finally { await env.restore(); }
176
+ });
177
+
178
+ test("自检·正常:kind=ok 且无任何提示文案(不制造噪音)", async () => {
179
+ const env = await makeEnv();
180
+ try {
181
+ writeRuntimeDir(env.relayDir);
182
+ writePairingConfig(env.relayDir);
183
+ const st = setupStateOf(env.relayDir);
184
+ assert.equal(st.kind, "ok");
185
+ assert.equal(st.runtimePresent, true);
186
+ assert.equal(st.configPresent, true);
187
+ assert.equal(st.text, "");
188
+ assert.equal(st.detail, "");
189
+ assert.equal(st.backup, null);
190
+ } finally { await env.restore(); }
191
+ });
192
+
193
+ // ══════════════════════════════════════════════════════════════════════════════
194
+ // ② 运行时自愈:复用既有 ensureRuntime(不动既有逻辑,只放宽"未登录也能补装")
195
+ // ══════════════════════════════════════════════════════════════════════════════
196
+
197
+ test("自愈:全缺失时 ensureConnection 用插件自带 runtime/ 补回运行时;配对绝不自填任何凭据", async () => {
198
+ const env = await makeEnv();
199
+ try {
200
+ const bundled = path.join(env.root, "bundled-runtime");
201
+ writeBundledRuntime(bundled);
202
+ process.env.DSH_RELAY_BUNDLED_RUNTIME = bundled;
203
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支(只做本地拷贝,不联网)
204
+ assert.equal(runtimeDirPresent(env.relayDir), false, "前提:运行时缺失");
205
+
206
+ const r = ensureConnection(env.relayDir);
207
+ assert.equal(r && r.action, "provision", "未登录 + 运行时缺失 → 先自愈补装运行环境(旧实现会直接 return null)");
208
+
209
+ assert.equal(runtimeDirPresent(env.relayDir), true, "运行时目录应被补回");
210
+ assert.ok(existsSync(path.join(env.relayDir, "clients", "dsh-remote", "dsh-bridge.mjs")), "bridge 本体应就位");
211
+ assert.equal(readFileSync(path.join(env.relayDir, ".dsh-setup-version"), "utf8").trim(), PLUGIN_VERSION,
212
+ "运行时版本标记应等于插件版本");
213
+ // ★ 安全红线:配对缺失必须由用户登录 —— 补装绝不创建/写入任何凭据
214
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-config.json")), false,
215
+ "自愈补装绝不能自动创建配对配置(那等于伪造账号)");
216
+ assert.equal(setupStateOf(env.relayDir).kind, "pairing_missing",
217
+ "补装完成后仍应如实报告「未配对」,引导用户登录");
218
+ } finally { await env.restore(); }
219
+ });
220
+
221
+ // ══════════════════════════════════════════════════════════════════════════════
222
+ // ③ 配对备份(双份):600/700、各保留 3 份、失败静默、日志绝不写内容
223
+ // ══════════════════════════════════════════════════════════════════════════════
224
+
225
+ test("双份备份:relay + profile 两处都生成 600 文件(目录 700),内容与配置一致,日志只写文件名", async () => {
226
+ const env = await makeEnv();
227
+ try {
228
+ const cfg = writePairingConfig(env.relayDir);
229
+ const r = backupConfigFile(env.relayDir);
230
+ assert.equal(r.ok, true, "有配置时必须备份成功");
231
+ assert.ok(r.sources && r.sources.relay, "应写入配置目录那一份");
232
+ assert.ok(r.sources && r.sources.profile, "应**同时**写入 profile 持久目录那一份(这次事故的核心:relayDir 可能整个被删)");
233
+ for (const [source, file] of [["relay", r.sources.relay], ["profile", r.sources.profile]]) {
234
+ const dir = configBackupDirOf(env.relayDir, source);
235
+ const filePath = path.join(dir, file);
236
+ assert.ok(existsSync(filePath), `${source} 备份文件应存在: ${filePath}`);
237
+ const dirMode = statSync(dir).mode & 0o777;
238
+ assert.equal(dirMode & 0o077, 0, `${source} 备份目录权限必须是 700(含明文密码/设备私钥),实际 ${dirMode.toString(8)}`);
239
+ const fileMode = statSync(filePath).mode & 0o777;
240
+ assert.equal(fileMode & 0o077, 0, `${source} 备份文件权限必须是 600,实际 ${fileMode.toString(8)}`);
241
+ assert.deepEqual(JSON.parse(readFileSync(filePath, "utf8")), cfg, `${source} 备份内容必须与当时配置一致(可恢复)`);
242
+ }
243
+ assert.equal(r.name, `profile:${r.sources.profile}`, "默认恢复应优先 profile 那份(name 带来源,可区分)");
244
+ // 日志里只有文件名与时间,绝无内容
245
+ const log = readLog(env.relayDir);
246
+ assert.ok(log.includes(`relay:${r.sources.relay}`), "日志应记录配置目录那份文件名(便于排查)");
247
+ assert.ok(log.includes(`profile:${r.sources.profile}`), "日志应记录持久目录那份文件名");
248
+ assert.ok(!log.includes(PW), "🔒 日志绝不能出现密码明文");
249
+ assert.ok(!log.includes(PRIVATE_KEY), "🔒 日志绝不能出现设备私钥");
250
+ assert.ok(!log.includes(PHONE), "日志不得记录明文手机号");
251
+ } finally { await env.restore(); }
252
+ });
253
+
254
+ test("双份备份:每处各保留最近 3 份(更老的自动清理,两个目录都成立)", async () => {
255
+ const env = await makeEnv();
256
+ try {
257
+ writePairingConfig(env.relayDir);
258
+ for (let i = 0; i < 5; i += 1) {
259
+ writePairingConfig(env.relayDir, { device_id: "dev-repair-backup-" + i });
260
+ const r = backupConfigFile(env.relayDir);
261
+ assert.equal(r.ok, true);
262
+ await new Promise((res) => setTimeout(res, 2)); // 拉开 mtime,模拟真实时间线
263
+ }
264
+ pruneConfigBackups(env.relayDir); // 幂等:重复清理不报错
265
+ assert.equal(BACKUP_KEEP, 3, "保留份数常量应为 3");
266
+ for (const source of ["relay", "profile"]) {
267
+ const kept = listConfigBackupsIn(env.relayDir, source);
268
+ assert.equal(kept.length, 3, `${source} 目录只保留最近 3 份,实际:${kept.map((b) => b.name).join(",")}`);
269
+ }
270
+ const merged = listConfigBackups(env.relayDir);
271
+ assert.equal(merged.length, 6, "两份来源合并后共 6 条(每份各 3)");
272
+ // 两份来源的 name 都可区分
273
+ const names = merged.map((b) => b.name);
274
+ assert.equal(new Set(names).size, names.length, "同名文件在两份来源里也要靠 name 前缀区分");
275
+ // 每份来源的最新一份都在(按 mtime 最新那条必须属于最后写入的那批)
276
+ for (const source of ["relay", "profile"]) {
277
+ const newest = listConfigBackupsIn(env.relayDir, source)[0];
278
+ assert.ok(newest && newest.name.startsWith(source + ":"), `${source} 最新一份应存在且带来源前缀`);
279
+ }
280
+ } finally { await env.restore(); }
281
+ });
282
+
283
+ test("备份失败静默:单点失败不影响另一份;两处都失败也不抛异常(不影响安装/登录)", async () => {
284
+ const env = await makeEnv();
285
+ try {
286
+ writePairingConfig(env.relayDir);
287
+ // 情形一:配置目录那份写不了(BACKUP_DIR 被占成普通文件)→ profile 那份仍应成功
288
+ writeFileSync(env.relayBackup(), "not a directory");
289
+ let partial;
290
+ assert.doesNotThrow(() => { partial = backupConfigFile(env.relayDir); }, "单点失败不得抛异常");
291
+ assert.equal(partial.ok, true, "两份里成功一份就算成功(另一份坏掉不应让整个备份失败)");
292
+ assert.ok(partial.sources.profile, "profile 那份应写成");
293
+ assert.equal(partial.sources.relay, undefined, "配置目录那份应如实报告未写成");
294
+
295
+ // 情形二:两处都失败 → 静默返回 ok:false(绝不抛)
296
+ const env2 = await makeEnv();
297
+ try {
298
+ writePairingConfig(env2.relayDir);
299
+ writeFileSync(env2.relayBackup(), "not a directory");
300
+ mkdirSync(path.join(pluginProfileDir(), PROFILE_DATA_DIR), { recursive: true });
301
+ writeFileSync(path.join(pluginProfileDir(), PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR), "not a directory");
302
+ let all;
303
+ assert.doesNotThrow(() => { all = backupConfigFile(env2.relayDir); }, "两份都失败也必须静默");
304
+ assert.equal(all.ok, false);
305
+ assert.equal(all.reason, "failed");
306
+ // 还没有配置时也是静默 no-op(不建目录、不抛错)
307
+ const none = backupConfigFile(path.join(env.root, "empty-relay-dir"));
308
+ assert.equal(none.ok, false);
309
+ assert.equal(none.reason, "no-config", "还没配对:无可备份(静默跳过)");
310
+ } finally { await env2.restore(); }
311
+ } finally { await env.restore(); }
312
+ });
313
+
314
+ // ══════════════════════════════════════════════════════════════════════════════
315
+ // ④ 有备份、主配置缺失:不自动恢复 + 状态提示 + 一键恢复(恢复前先备份当前状态)
316
+ // ══════════════════════════════════════════════════════════════════════════════
317
+
318
+ test("有备份、主配置缺失:**不自动恢复**,状态里提示备份时间/来源并给出恢复动作", async () => {
319
+ const env = await makeEnv();
320
+ try {
321
+ writeRuntimeDir(env.relayDir);
322
+ // 手工造一份历史备份(模拟"上次配对成功时留下的备份")
323
+ const oldCfg = { phone: PHONE, password: PW, device_id: "dev-old-account", api_url: "http://127.0.0.1:1" };
324
+ const name = "dsh-config-2026-10-17T09-30-00-000Z.json";
325
+ writeManualBackup(env.relayBackup(), name, oldCfg);
326
+
327
+ const st = setupStateOf(env.relayDir);
328
+ assert.equal(st.kind, "pairing_missing", "主配置不在 → 未配对");
329
+ assert.ok(st.backup && st.backup.available, "应检测到历史配对备份");
330
+ assert.equal(st.backup.source, "relay", "这份来自配置目录");
331
+ assert.equal(st.backup.name, `relay:${name}`, "name 必须带来源前缀(profile:/relay:),可区分");
332
+ assert.match(st.backup.text, /检测到历史配对备份(/, "提示里要带「检测到历史配对备份(时间)」");
333
+ assert.match(st.backup.text, /可一键恢复/, "提示要明确可一键恢复");
334
+ assert.equal(st.backup.action.kind, "restore_config");
335
+ assert.equal(st.backup.action.method, "POST");
336
+ assert.equal(st.backup.action.path, "/dsh-remote/config/restore", "恢复动作必须指向真实路由");
337
+ assert.deepEqual(st.backup.action.body, { backup: `relay:${name}` });
338
+
339
+ // ★ 关键:只是"检测到",绝不自动恢复
340
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-config.json")), false,
341
+ "发现备份时**不得自动恢复**(旧账号可能是别人的/已失效,误用会把设备绑到错误账号)");
342
+ // 🔒 状态对象里不得带任何凭据内容
343
+ const json = JSON.stringify(st);
344
+ assert.ok(!json.includes(PW), "🔒 状态里绝不能出现密码明文");
345
+ assert.ok(!json.includes(PRIVATE_KEY), "🔒 状态里绝不能出现设备私钥");
346
+ } finally { await env.restore(); }
347
+ });
348
+
349
+ test("一键恢复:内容回到主配置、权限 600;恢复前先把当前状态再备份一份(双份都可回滚)", async () => {
350
+ const env = await makeEnv();
351
+ try {
352
+ writeRuntimeDir(env.relayDir);
353
+ const oldCfg = { phone: PHONE, password: PW, device_id: "dev-old-account", api_url: "http://127.0.0.1:1" };
354
+ const name = "dsh-config-2026-10-17T09-30-00-000Z.json";
355
+ writeManualBackup(env.relayBackup(), name, oldCfg);
356
+ await new Promise((res) => setTimeout(res, 2));
357
+ // 主配置是"新状态"(比如用户后来重新登录后写的)→ 恢复前必须能找回它
358
+ const newCfg = writePairingConfig(env.relayDir, { device_id: "dev-new-account" });
359
+
360
+ const r = restoreConfigBackup(env.relayDir, name); // 裸文件名(兼容旧状态)也要能恢复
361
+ assert.equal(r.ok, true, "恢复应成功");
362
+ assert.equal(r.restored, `relay:${name}`);
363
+ assert.equal(r.source, "relay");
364
+ assert.deepEqual(mainConfigOf(env.relayDir), oldCfg, "主配置内容应恢复为备份内容");
365
+ const mode = statSync(path.join(env.relayDir, ".dsh-config.json")).mode & 0o777;
366
+ assert.equal(mode & 0o077, 0, `恢复后的主配置权限必须是 600,实际 ${mode.toString(8)}`);
367
+
368
+ // 恢复前把当前状态再备份了一份 —— 两个目录里都应能找到(回滚点)
369
+ for (const source of ["relay", "profile"]) {
370
+ const found = listConfigBackupsIn(env.relayDir, source).find((b) => {
371
+ try {
372
+ return JSON.stringify(JSON.parse(readFileSync(path.join(configBackupDirOf(env.relayDir, source), b.file), "utf8"))) === JSON.stringify(newCfg);
373
+ } catch { return false; }
374
+ });
375
+ assert.ok(found, `${source} 目录里应能找到「恢复前的当前状态」以便回滚`);
376
+ }
377
+ const log = readLog(env.relayDir);
378
+ assert.match(log, /一键恢复配对配置/, "恢复动作应留痕(只写文件名/时间/来源)");
379
+ assert.ok(log.includes(`relay:${name}`), "日志应写恢复源的 name");
380
+ assert.ok(!log.includes(PW), "🔒 恢复日志绝不能出现密码明文");
381
+ } finally { await env.restore(); }
382
+ });
383
+
384
+ test("一键恢复:不存在的文件名 / 路径穿越(含 profile:relay: 前缀形态)必须被拒绝", async () => {
385
+ const env = await makeEnv();
386
+ try {
387
+ writeRuntimeDir(env.relayDir);
388
+ const name = "dsh-config-2026-10-17T09-30-00-000Z.json";
389
+ writeManualBackup(env.relayBackup(), name, { phone: PHONE });
390
+
391
+ for (const bad of [
392
+ "../../../etc/passwd",
393
+ "profile:../../../etc/passwd",
394
+ "relay:../../etc/passwd",
395
+ "profile:" + name.replace(/\.json$/, "-nope.json"),
396
+ "relay:/etc/passwd",
397
+ ]) {
398
+ const r = restoreConfigBackup(env.relayDir, bad);
399
+ assert.equal(r.ok, false, "非法备份引用必须被拒绝: " + bad);
400
+ assert.equal(r.reason, "no-such-backup", "应报 no-such-backup: " + bad);
401
+ }
402
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-config.json")), false, "拒绝时不得写主配置");
403
+ // 一份备份都没有时
404
+ const env2 = await makeEnv();
405
+ try {
406
+ const none = restoreConfigBackup(env2.relayDir);
407
+ assert.equal(none.ok, false);
408
+ assert.equal(none.reason, "no-backup");
409
+ } finally { await env2.restore(); }
410
+ } finally { await env.restore(); }
411
+ });
412
+
413
+ // ══════════════════════════════════════════════════════════════════════════════
414
+ // ⑤ 真机事故的核心场景:**整个 relayDir 被删,profile 那份还在**
415
+ // ══════════════════════════════════════════════════════════════════════════════
416
+
417
+ test("★ 整个 ~/.dsh-remote 被删(relayDir 没了)→ profile 持久目录那份仍在:状态提示可恢复且能真的恢复", async () => {
418
+ const env = await makeEnv();
419
+ try {
420
+ writeRuntimeDir(env.relayDir);
421
+ const cfg = writePairingConfig(env.relayDir);
422
+ const r = backupConfigFile(env.relayDir);
423
+ assert.ok(r.sources.profile, "前提:profile 持久目录那份已写好");
424
+ const profileFile = path.join(configBackupDirOf(env.relayDir, "profile"), r.sources.profile);
425
+ assert.ok(existsSync(profileFile));
426
+
427
+ // —— 真机事故:用户把整个配置目录(运行时 + 配对 + relayDir 内备份)一起删掉 ——
428
+ rmSync(env.relayDir, { recursive: true, force: true });
429
+ assert.equal(existsSync(env.relayDir), false, "前提:整个 relayDir 已不存在");
430
+
431
+ const st = setupStateOf(env.relayDir);
432
+ assert.equal(st.kind, "all_missing", "运行时与配对都随目录消失");
433
+ assert.ok(st.backup && st.backup.available,
434
+ "★ 整个 relayDir 被删后仍必须能从 profile 持久目录发现备份(这正是本次加固要解决的边界)");
435
+ assert.equal(st.backup.source, "profile");
436
+ assert.match(st.backup.name, /^profile:dsh-config-/, "name 必须标明来源 profile");
437
+ assert.match(st.backup.text, /插件持久目录/, "提示里要说明这份来自插件持久目录");
438
+ assert.equal(st.backup.action.body.backup, st.backup.name, "恢复动作要带上这份的具体 name");
439
+
440
+ // 状态查询本身绝不自动恢复
441
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-config.json")), false);
442
+
443
+ // 一键恢复:relayDir 会被重建,配对信息回来,权限 600
444
+ const restored = restoreConfigBackup(env.relayDir);
445
+ assert.equal(restored.ok, true, "★ 必须能真的从 profile 那份恢复回来");
446
+ assert.equal(restored.source, "profile");
447
+ assert.deepEqual(mainConfigOf(env.relayDir), cfg, "恢复出的配对信息必须与备份一致");
448
+ const mode = statSync(path.join(env.relayDir, ".dsh-config.json")).mode & 0o777;
449
+ assert.equal(mode & 0o077, 0, "恢复后的主配置权限必须是 600");
450
+ assert.ok(existsSync(profileFile), "恢复不应消耗/删除那份备份(还能再回滚)");
451
+ assert.equal(setupStateOf(env.relayDir).backup, null, "主配置回来后不再提示恢复(configPresent=true)");
452
+ } finally { await env.restore(); }
453
+ });
454
+
455
+ test("两份都在:name 可区分(profile:/relay:)、清单都可见;默认恢复优先 profile,显式 relay: 仍可恢复指定那份", async () => {
456
+ const env = await makeEnv();
457
+ try {
458
+ writeRuntimeDir(env.relayDir);
459
+ const cfg = writePairingConfig(env.relayDir);
460
+ const r = backupConfigFile(env.relayDir);
461
+ const relayFile = r.sources.relay;
462
+ const profileFile = r.sources.profile;
463
+ assert.equal(relayFile, profileFile, "同一次写入,两处文件名相同(时间戳一致)");
464
+
465
+ // 让两份内容不同:把 relay 那份改成"另一个历史状态"(模拟旧版只写 relay / 手工恢复过的历史)
466
+ const relayVariant = { phone: PHONE, password: PW, device_id: "dev-relay-only-variant", api_url: "http://127.0.0.1:1" };
467
+ const relayPath = path.join(configBackupDirOf(env.relayDir, "relay"), relayFile);
468
+ writeFileSync(relayPath, JSON.stringify(relayVariant));
469
+ // 恢复原 mtime,保证"两份时间相同"→ 默认优先级由来源决定(profile 胜)
470
+ utimesSync(relayPath, new Date(r.at), new Date(r.at));
471
+ rmSync(path.join(env.relayDir, ".dsh-config.json")); // 让 backup 提示出现
472
+
473
+ const st = setupStateOf(env.relayDir);
474
+ assert.equal(st.backup.name, `profile:${profileFile}`, "两份都在时默认选中 profile 那份");
475
+ assert.equal(st.backup.source, "profile");
476
+ assert.ok(Array.isArray(st.backup.sources) && st.backup.sources.length === 2, "状态里应能看到两份清单");
477
+ assert.deepEqual(st.backup.sources.map((s) => s.name).sort(),
478
+ [`profile:${profileFile}`, `relay:${relayFile}`].sort(), "两份 name 必须可区分");
479
+
480
+ // 默认恢复(不带 name)→ profile 内容(不是 relay variant)
481
+ const dflt = restoreConfigBackup(env.relayDir);
482
+ assert.equal(dflt.ok, true);
483
+ assert.equal(dflt.source, "profile");
484
+ assert.deepEqual(mainConfigOf(env.relayDir), cfg, "默认恢复必须用 profile 那份(时间相同时 profile 优先:抗整目录删除)");
485
+
486
+ // 显式指定 relay: 前缀 → 恢复 relay variant
487
+ const byRelay = restoreConfigBackup(env.relayDir, `relay:${relayFile}`);
488
+ assert.equal(byRelay.ok, true, "显式 relay: 也必须能恢复(用户可能明确要另一份)");
489
+ assert.equal(byRelay.source, "relay");
490
+ assert.equal(byRelay.restored, `relay:${relayFile}`);
491
+ assert.deepEqual(mainConfigOf(env.relayDir), relayVariant, "显式 relay: 应恢复到 relay 那份内容");
492
+ } finally { await env.restore(); }
493
+ });
494
+
495
+ // ══════════════════════════════════════════════════════════════════════════════
496
+ // ⑥ 卸载:profile 数据目录里的第二份配对备份必须一起清掉
497
+ // ══════════════════════════════════════════════════════════════════════════════
498
+
499
+ test("★ 彻底卸载:profile 数据目录里的第二份配对备份一起删(不把账号密钥副本留给已卸载的用户)", async () => {
500
+ const env = await makeEnv();
501
+ try {
502
+ const profileDir = pluginProfileDir();
503
+ assert.ok(profileDir.startsWith(env.fakeHome), "安全自证②:profile 目录必须落在假 HOME 里,实际 " + profileDir);
504
+ const dataDir = path.join(profileDir, PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR);
505
+ writeManualBackup(dataDir, "dsh-config-2026-10-17T09-30-00-000Z.json", { phone: PHONE, password: PW });
506
+ mkdirSync(path.join(profileDir, "dsh-remote-web-plugin"), { recursive: true });
507
+
508
+ const out = uninstallSelf(
509
+ env.relayDir, profileDir,
510
+ path.join(profileDir, "cordis.patch.yml"),
511
+ path.join(profileDir, "package.json"),
512
+ );
513
+ assert.equal(out.removedDir, true);
514
+ assert.equal(existsSync(path.join(profileDir, "dsh-remote-web-plugin")), false, "插件目录应被移除");
515
+ assert.equal(existsSync(path.join(profileDir, PROFILE_DATA_DIR)), false,
516
+ "★ 彻底卸载必须清掉 profile 数据目录(里面是账号密码/设备私钥的第二份备份)");
517
+ } finally { await env.restore(); }
518
+ });
519
+
520
+ // ══════════════════════════════════════════════════════════════════════════════
521
+ // ⑦ 对外状态(open API):/status 与 /bridge-status 下发 setup;恢复走真实路由
522
+ // ══════════════════════════════════════════════════════════════════════════════
523
+
524
+ /** 装载插件路由(收集 disposer,避免自愈定时器跨用例继续跑)。 */
525
+ function boot(relayDir) {
526
+ const routes = new Map();
527
+ const disposers = [];
528
+ apply({
529
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
530
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
531
+ logger: { info() {}, warn() {} },
532
+ }, { relayDir });
533
+ routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
534
+ return routes;
535
+ }
536
+
537
+ async function serve(routes) {
538
+ const host = http.createServer((req, res) => {
539
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
540
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
541
+ });
542
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
543
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
544
+ }
545
+
546
+ test("open API:/status 与 /bridge-status 都下发 setup;只有 profile 那份备份时,恢复路由照样能把配对救回来", async () => {
547
+ const env = await makeEnv();
548
+ try {
549
+ // 现场:relayDir 里既没运行时也没配对,**只有 profile 持久目录那份历史备份**
550
+ const name = "dsh-config-2026-10-17T09-30-00-000Z.json";
551
+ const oldCfg = { phone: PHONE, password: PW, device_id: "dev-old", api_url: "http://127.0.0.1:1" };
552
+ writeManualBackup(configBackupDirOf(env.relayDir, "profile"), name, oldCfg);
553
+ assert.equal(existsSync(env.relayDir), true, "relayDir 壳还在(但里面空)");
554
+ assert.equal(existsSync(path.join(env.relayDir, "clients")), false);
555
+
556
+ const routes = boot(env.relayDir);
557
+ const srv = await serve(routes);
558
+ try {
559
+ const st = await (await fetch(`${srv.base}/dsh-remote/status`)).json();
560
+ assert.equal(st.ok, true);
561
+ assert.ok(st.setup, "/status 必须下发 setup(面板据此显示「需要重新安装/登录」)");
562
+ assert.equal(st.setup.kind, "all_missing", "运行时与配对都不在");
563
+ assert.match(st.setup.text, /运行环境缺失/);
564
+ assert.match(st.setup.text, /未配对/);
565
+ assert.equal(st.setup.backup.available, true, "仅 profile 有备份时也必须 available");
566
+ assert.equal(st.setup.backup.source, "profile");
567
+ assert.equal(st.setup.backup.name, `profile:${name}`);
568
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-config.json")), false, "查询状态绝不自动恢复备份");
569
+
570
+ const bs = await (await fetch(`${srv.base}/dsh-remote/bridge-status`)).json();
571
+ assert.equal(bs.ok, true);
572
+ assert.ok(bs.connect.setup, "/bridge-status 也要下发同一份 setup(2~3s 短轮询可以看到)");
573
+ assert.equal(bs.connect.setup.kind, "all_missing");
574
+ assert.match(String(bs.connect.detail), /运行环境缺失/, "需要用户动手的结论要前置到 detail(旧客户端也看得到)");
575
+ assert.ok(!JSON.stringify(bs).includes(PW), "🔒 open API 响应绝不能出现密码明文");
576
+
577
+ // 按状态里下发的 action 一字不改地调真实路由
578
+ const action = st.setup.backup.action;
579
+ const res = await fetch(`${srv.base}${action.path}`, {
580
+ method: action.method,
581
+ headers: { "content-type": "application/json" },
582
+ body: JSON.stringify(action.body),
583
+ });
584
+ assert.equal(res.status, 200, "有备份时恢复路由应成功");
585
+ const body = await res.json();
586
+ assert.equal(body.ok, true);
587
+ assert.equal(body.restored, `profile:${name}`);
588
+ assert.deepEqual(JSON.parse(readFileSync(path.join(env.relayDir, ".dsh-config.json"), "utf8")), oldCfg,
589
+ "真实路由恢复后主配置应等于 profile 那份备份内容");
590
+ assert.equal(body.setup.configPresent, true, "恢复后 configPresent 应为 true");
591
+ assert.equal(body.setup.kind, "runtime_missing",
592
+ "恢复后 setup 应刷新:配对已回来、运行时仍缺失 → 明确提示重新安装(而不是继续说未配对)");
593
+ assert.ok(!JSON.stringify(body).includes(PW), "🔒 恢复响应也绝不能出现密码明文");
594
+ } finally {
595
+ srv.host.close();
596
+ routes.dispose();
597
+ }
598
+ } finally { await env.restore(); }
599
+ });