pi-webdav-backup 1.0.0

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,268 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-secrets.mjs — 密钥存储层测试
4
+ *
5
+ * 目标:确认配置文件里可以完全不出现明文密钥。
6
+ * 覆盖 dpapi: / $ENV / file: / !command / plain: / 字面量 各分支,
7
+ * 以及明文迁移。
8
+ */
9
+
10
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ let pass = 0;
15
+ let fail = 0;
16
+ const lines = [];
17
+ const check = (n, c, d = "") => {
18
+ if (c) {
19
+ pass++;
20
+ lines.push(` ✅ ${n}`);
21
+ } else {
22
+ fail++;
23
+ lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
24
+ }
25
+ };
26
+
27
+ const agentDir = mkdtempSync(join(tmpdir(), "pi-secrets-test-"));
28
+ process.env.PI_CODING_AGENT_DIR = agentDir;
29
+
30
+ const S = await import("./secrets.mjs");
31
+ const { loadConfig } = await import("./config.mjs");
32
+ const { migrateSecrets } = await import("./migrate.mjs");
33
+
34
+ console.log("pi WebDAV 备份 — 密钥存储测试\n");
35
+
36
+ // ── 1. 明文判定
37
+ check("明文被识别", S.isPlaintextSecret("MyPassword123") === true);
38
+ check("dpapi: 不算明文", S.isPlaintextSecret("dpapi:a") === false);
39
+ check("$ENV 不算明文", S.isPlaintextSecret("$FOO") === false);
40
+ check("${ENV} 不算明文", S.isPlaintextSecret("${FOO}") === false);
41
+ check("file: 不算明文", S.isPlaintextSecret("file:/x") === false);
42
+ check("!command 不算明文", S.isPlaintextSecret("!pass show x") === false);
43
+ check("plain: 不算明文(但会在解析时告警)", S.isPlaintextSecret("plain:x") === false);
44
+ check("空值不算明文", S.isPlaintextSecret("") === false);
45
+
46
+ // ── 2. 环境变量解析
47
+ process.env.PI_TEST_SECRET = "env-secret-value";
48
+ const r1 = S.resolveSecret("$PI_TEST_SECRET");
49
+ check("$VAR 解析成功", r1.value === "env-secret-value", JSON.stringify(r1));
50
+ check("$VAR 来源标记为 env", r1.source === "env");
51
+
52
+ const r2 = S.resolveSecret("${PI_TEST_SECRET}");
53
+ check("${VAR} 解析成功", r2.value === "env-secret-value", JSON.stringify(r2));
54
+
55
+ const r3 = S.resolveSecret("pre-$PI_TEST_SECRET-post");
56
+ check("模板插值", r3.value === "pre-env-secret-value-post", JSON.stringify(r3));
57
+
58
+ const r4 = S.resolveSecret("$DEFINITELY_NOT_SET_VAR_9");
59
+ check("缺失环境变量给出错误", r4.value === undefined && /环境变量未设置/.test(r4.error), JSON.stringify(r4));
60
+
61
+ check("$$ 转义为字面量 $", S.resolveSecret("$$literal").value === "$literal");
62
+ check("$! 转义为字面量 !", S.resolveSecret("$!literal").value === "!literal");
63
+
64
+ // ── 3. file: 解析
65
+ const secretFile = join(agentDir, "pw.txt");
66
+ writeFileSync(secretFile, "file-secret-value\n");
67
+ const r5 = S.resolveSecret(`file:${secretFile}`);
68
+ check("file: 解析并去换行", r5.value === "file-secret-value", JSON.stringify(r5));
69
+ check("file: 支持相对 agentDir 路径", (() => {
70
+ writeFileSync(join(agentDir, "rel.txt"), "rel-secret");
71
+ return S.resolveSecret("file:rel.txt").value === "rel-secret";
72
+ })());
73
+ check("file: 不存在时报错", (() => {
74
+ const r = S.resolveSecret("file:nope-not-here.txt");
75
+ return r.value === undefined && /不存在/.test(r.error);
76
+ })());
77
+ writeFileSync(join(agentDir, "empty.txt"), " \n");
78
+ check("file: 空文件报错", (() => {
79
+ const r = S.resolveSecret("file:empty.txt");
80
+ return r.value === undefined && /为空/.test(r.error);
81
+ })());
82
+
83
+ // ── 4. 命令解析
84
+ const r6 = S.resolveSecret("!echo cmd-secret");
85
+ check("!command 取 stdout", r6.value === "cmd-secret", JSON.stringify(r6));
86
+ check("!command 来源标记", r6.source === "command");
87
+ check("!command 失败报错", (() => {
88
+ const r = S.resolveSecret("!exit 3");
89
+ return r.value === undefined && /命令执行失败|无输出/.test(r.error);
90
+ })());
91
+
92
+ // ── 5. plain: 显式明文
93
+ const r7 = S.resolveSecret("plain:explicit-plaintext");
94
+ check("plain: 解析出值", r7.value === "explicit-plaintext");
95
+ check("plain: 给出告警", typeof r7.warning === "string" && r7.warning.length > 0, JSON.stringify(r7));
96
+
97
+ // ── 6. 字面量即明文,必须告警
98
+ const r8 = S.resolveSecret("barePlaintext123");
99
+ check("字面量解析出值", r8.value === "barePlaintext123");
100
+ check("字面量给出明文告警", /明文/.test(String(r8.warning)), JSON.stringify(r8));
101
+ check("allowPlain=false 时拒绝明文", (() => {
102
+ const r = S.resolveSecret("barePlaintext123", { allowPlain: false });
103
+ return r.value === undefined && !!r.error;
104
+ })());
105
+
106
+ // ── 7. DPAPI 往返(Windows)
107
+ if (S.dpapiAvailable()) {
108
+ S.setDpapiSecret("unit-test-secret", "dpapi-roundtrip-value");
109
+ const r9 = S.resolveSecret("dpapi:unit-test-secret");
110
+ check("DPAPI 往返解密正确", r9.value === "dpapi-roundtrip-value", JSON.stringify(r9));
111
+ check("DPAPI 来源标记", r9.source === "dpapi");
112
+
113
+ const listed = S.listDpapiSecrets();
114
+ check("listDpapiSecrets 含该条目", listed.some((s) => s.name === "unit-test-secret"));
115
+ check("列表不含明文", !JSON.stringify(listed).includes("dpapi-roundtrip-value"));
116
+
117
+ const storeRaw = readFileSync(S.dpapiStorePath(), "utf8");
118
+ check("DPAPI 仓库文件不含明文", !storeRaw.includes("dpapi-roundtrip-value"));
119
+ check("DPAPI 仓库文件记录了 scope", /CurrentUser/.test(storeRaw));
120
+
121
+ check("不存在的 DPAPI 名称报错", (() => {
122
+ const r = S.resolveSecret("dpapi:no-such-name");
123
+ return r.value === undefined && /找不到/.test(r.error);
124
+ })());
125
+
126
+ check("删除 DPAPI 密钥", S.deleteDpapiSecret("unit-test-secret") === true);
127
+ check("删除后解析失败", S.resolveSecret("dpapi:unit-test-secret").value === undefined);
128
+ } else {
129
+ lines.push(" ⏭ 跳过 DPAPI 测试(非 Windows)");
130
+ }
131
+
132
+ // ── 8. 配置层集成:明文 → 告警;引用 → 无告警
133
+ const cfgPath = join(agentDir, "webdav-backup.json");
134
+
135
+ writeFileSync(
136
+ cfgPath,
137
+ JSON.stringify({
138
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "PlaintextPw123" },
139
+ encrypt: false,
140
+ }),
141
+ );
142
+ const c1 = loadConfig();
143
+ check("明文密码仍可用(向后兼容)", c1.ok && c1.config.password === "PlaintextPw123", JSON.stringify(c1.errors));
144
+ check("明文密码触发告警", c1.warnings.some((w) => /明文/.test(w)), JSON.stringify(c1.warnings));
145
+ check("密码来源标记为 literal", c1.secrets.passwordSource === "literal");
146
+
147
+ closeNoop();
148
+ function closeNoop() {}
149
+
150
+ process.env.PI_TEST_PW = "from-env-pw";
151
+ writeFileSync(
152
+ cfgPath,
153
+ JSON.stringify({
154
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "$PI_TEST_PW" },
155
+ encrypt: false,
156
+ }),
157
+ );
158
+ const c2 = loadConfig();
159
+ check("$ENV 密码解析正确", c2.config.password === "from-env-pw", JSON.stringify(c2));
160
+ check("$ENV 密码无明文告警", !c2.warnings.some((w) => /明文/.test(w)), JSON.stringify(c2.warnings));
161
+
162
+ // 缺失环境变量应阻断
163
+ writeFileSync(
164
+ cfgPath,
165
+ JSON.stringify({
166
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "$NOT_SET_PW_VAR" },
167
+ }),
168
+ );
169
+ const c3 = loadConfig();
170
+ check("缺失环境变量导致配置失败", c3.ok === false, JSON.stringify(c3.errors));
171
+ check("错误信息指明变量名", c3.errors.some((e) => /NOT_SET_PW_VAR/.test(e)), JSON.stringify(c3.errors));
172
+
173
+ // encryptKey 明文告警
174
+ writeFileSync(
175
+ cfgPath,
176
+ JSON.stringify({
177
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "$PI_TEST_PW" },
178
+ encrypt: true,
179
+ encryptKey: "plaintext-key-abcdefg",
180
+ }),
181
+ );
182
+ const c4 = loadConfig();
183
+ check("encryptKey 明文触发告警", c4.warnings.some((w) => /encryptKey 是明文/.test(w)), JSON.stringify(c4.warnings));
184
+
185
+ // ── 9. 迁移:明文 → DPAPI
186
+ if (S.dpapiAvailable()) {
187
+ writeFileSync(
188
+ cfgPath,
189
+ JSON.stringify({
190
+ remote: {
191
+ url: "https://dav.example.com/dav/",
192
+ username: "u",
193
+ password: "MigrateMePassword123",
194
+ remoteDir: "bk",
195
+ remoteName: "pi",
196
+ },
197
+ encrypt: true,
198
+ encryptKey: "MigrateMeKeyPassphrase",
199
+ keepVersions: 5,
200
+ }),
201
+ );
202
+
203
+ const dry = await migrateSecrets({ method: "dpapi", log: () => {}, dryRun: true });
204
+ check("迁移 dry-run 报告待迁移项", dry.ok && dry.wouldMigrate?.length === 2, JSON.stringify(dry));
205
+
206
+ const mig = await migrateSecrets({ method: "dpapi", log: () => {} });
207
+ check("迁移执行成功", mig.ok && mig.migrated === 2, JSON.stringify(mig));
208
+ check("迁移前自动备份配置", !!mig.backup && existsSync(mig.backup));
209
+
210
+ const after = JSON.parse(readFileSync(cfgPath, "utf8"));
211
+ check("password 已变为 dpapi 引用", String(after.remote.password).startsWith("dpapi:"), after.remote.password);
212
+ check("encryptKey 已变为 dpapi 引用", String(after.encryptKey).startsWith("dpapi:"), after.encryptKey);
213
+ check("配置文件不再含明文密码", !readFileSync(cfgPath, "utf8").includes("MigrateMePassword123"));
214
+ check("配置文件不再含明文密钥", !readFileSync(cfgPath, "utf8").includes("MigrateMeKeyPassphrase"));
215
+ check("其他字段保持不变", after.keepVersions === 5 && after.remote.remoteDir === "bk");
216
+
217
+ const c5 = loadConfig();
218
+ check("迁移后配置仍可加载", c5.ok, JSON.stringify(c5.errors));
219
+ check("迁移后密码可解密", c5.config.password === "MigrateMePassword123");
220
+ check("迁移后 encryptKey 可解密", c5.config.encryptKey === "MigrateMeKeyPassphrase");
221
+ check("迁移后无明文告警", !c5.warnings.some((w) => /明文/.test(w)), JSON.stringify(c5.warnings));
222
+
223
+ const again = await migrateSecrets({ method: "dpapi", log: () => {} });
224
+ check("重复迁移无副作用", again.ok && again.migrated === 0, JSON.stringify(again));
225
+ } else {
226
+ lines.push(" ⏭ 跳过迁移测试(非 Windows)");
227
+ }
228
+
229
+ // ── 10. 迁移:明文 → file
230
+ writeFileSync(
231
+ cfgPath,
232
+ JSON.stringify({
233
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "FileModePassword1" },
234
+ encrypt: false,
235
+ }),
236
+ );
237
+ const mf = await migrateSecrets({ method: "file", log: () => {} });
238
+ check("file 方式迁移成功", mf.ok === true, JSON.stringify(mf));
239
+ const afterF = JSON.parse(readFileSync(cfgPath, "utf8"));
240
+ check("file 引用已写入配置", String(afterF.remote.password).startsWith("file:"), afterF.remote.password);
241
+ check("secrets 目录已创建", existsSync(join(agentDir, "secrets")));
242
+ const c6 = loadConfig();
243
+ check("file 方式迁移后可加载", c6.ok && c6.config.password === "FileModePassword1", JSON.stringify(c6.errors));
244
+
245
+ // ── 11. 迁移:明文 → env
246
+ writeFileSync(
247
+ cfgPath,
248
+ JSON.stringify({
249
+ remote: { url: "https://dav.example.com/dav/", username: "u", password: "EnvModePassword1" },
250
+ encrypt: false,
251
+ }),
252
+ );
253
+ const me = await migrateSecrets({ method: "env", log: () => {} });
254
+ check("env 方式迁移成功", me.ok === true, JSON.stringify(me));
255
+ const afterE = JSON.parse(readFileSync(cfgPath, "utf8"));
256
+ check("env 引用已写入配置", afterE.remote.password === "$PI_WEBDAV_BACKUP_PASSWORD", afterE.remote.password);
257
+ check("env 方式不再含明文", !readFileSync(cfgPath, "utf8").includes("EnvModePassword1"));
258
+
259
+ // 清理
260
+ try {
261
+ rmSync(agentDir, { recursive: true, force: true });
262
+ } catch {
263
+ /* ignore */
264
+ }
265
+
266
+ console.log(lines.join("\n"));
267
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
268
+ process.exit(fail === 0 ? 0 : 1);