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,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-redact.mjs — 敏感信息脱敏测试(安全关键路径)
4
+ *
5
+ * 背景:早期实现只按字段名(key/token/...)匹配,
6
+ * 形如 {"k": "sk-xxxx"} 的凭据会被【原样上传到云端】。
7
+ * 这里锁定修复后的行为。
8
+ */
9
+
10
+ import { redactSensitive } from "./backup.mjs";
11
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { startDavServer } from "./dav-mock.mjs";
16
+ import { runBackup } from "./backup.mjs";
17
+ import { collectFiles } from "./collect.mjs";
18
+
19
+ let pass = 0;
20
+ let fail = 0;
21
+ const lines = [];
22
+ const check = (n, c, d = "") => {
23
+ if (c) {
24
+ pass++;
25
+ lines.push(` ✅ ${n}`);
26
+ } else {
27
+ fail++;
28
+ lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
29
+ }
30
+ };
31
+
32
+ const b = (s) => Buffer.from(s, "utf8");
33
+ const redact = (rel, text, opts) => {
34
+ const r = redactSensitive(rel, b(text), opts);
35
+ return r ? r.toString("utf8") : null;
36
+ };
37
+
38
+ console.log("pi WebDAV 备份 — 脱敏安全性测试\n");
39
+
40
+ // ── 1. 字段名匹配(传统情况)
41
+ const r1 = redact("auth.json", JSON.stringify({ openai: { key: "sk-abc123", type: "api" } }));
42
+ check("字段名 key 被脱敏", !r1.includes("sk-abc123"), r1);
43
+ check("非敏感字段保留", r1.includes('"type": "api"') || r1.includes('"type":"api"'));
44
+
45
+ // ── 2. 字段名不含敏感词,但值是凭据(回归:早期版本会泄漏)
46
+ const r2 = redact("auth.json", JSON.stringify({ p: { k: "sk-SECRET-CANARY-1234567890" } }));
47
+ check("字段名无关但值像密钥 → 脱敏(回归)", !r2.includes("sk-SECRET-CANARY"), r2);
48
+
49
+ const r3 = redact("auth.json", JSON.stringify({ a: { b: "ghp_abcdefghijklmnopqrstuvwxyz0123456789" } }));
50
+ check("GitHub token 值被识别", !r3.includes("ghp_abcdefghij"), r3);
51
+
52
+ const r4 = redact("auth.json", JSON.stringify({ x: "AIzaSyA1234567890abcdefghijklmnopqrst" }));
53
+ check("Google API key 值被识别", !r4.includes("AIzaSy"), r4);
54
+
55
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop";
56
+ const r5 = redact("auth.json", JSON.stringify({ t: jwt }));
57
+ check("JWT 值被识别", !r5.includes("eyJhbGciOiJIUzI1NiJ9"), r5);
58
+
59
+ // ── 3. 深层嵌套与数组
60
+ const r6 = redact(
61
+ "auth.json",
62
+ JSON.stringify({ providers: [{ name: "a", creds: { token: "tok-1234567890" } }], list: ["sk-deep-secret-value-1"] }),
63
+ );
64
+ check("嵌套对象内的 token 被脱敏", !r6.includes("tok-1234567890"), r6);
65
+ check("数组内的凭据被脱敏", !r6.includes("sk-deep-secret-value-1"), r6);
66
+ check("脱敏后仍是合法 JSON", (() => { try { JSON.parse(r6); return true; } catch { return false; } })());
67
+
68
+ // ── 4. 不应误伤普通值
69
+ const r7 = redact("auth.json", JSON.stringify({ model: "claude-sonnet-4", theme: "dark", count: 42 }));
70
+ check("普通短字符串不被脱敏", r7.includes("claude-sonnet-4") && r7.includes("dark"), r7);
71
+
72
+ // ── 5. 无法解析时绝不原样上传
73
+ try {
74
+ const r8 = redact("auth.json", "{ not valid json ");
75
+ check("无法解析的 auth.json 不泄漏原文", r8 !== null && !r8.includes("not valid json"), String(r8));
76
+ } catch {
77
+ check("无法解析的 auth.json 不泄漏原文", false, "抛出了异常");
78
+ }
79
+
80
+ // ── 6. 配置文件:密码与加密密钥
81
+ const cfgText = JSON.stringify({
82
+ remote: { url: "https://dav.example.com/dav/", username: "me@example.com", password: "MyRealPassword123", remoteDir: "bk" },
83
+ encryptKey: "super-secret-passphrase",
84
+ encrypt: false,
85
+ });
86
+ const r9 = redact("webdav-backup.json", cfgText);
87
+ check("配置里的 password 被脱敏", !r9.includes("MyRealPassword123"), r9);
88
+ check("配置里的 encryptKey 被脱敏", !r9.includes("super-secret-passphrase"), r9);
89
+ check("配置里的 url 保留(便于诊断)", r9.includes("dav.example.com"), r9);
90
+
91
+ // ── 7. 加密备份时(aggressive=false)仍保护已知字段
92
+ const r10 = redact("webdav-backup.json", cfgText, { aggressive: false });
93
+ check("aggressive=false 仍脱敏 password", !r10.includes("MyRealPassword123"), r10);
94
+
95
+ const r11 = redact("auth.json", JSON.stringify({ openai: { key: "sk-known-field" } }), { aggressive: false });
96
+ check("aggressive=false 仍按字段名脱敏", !r11.includes("sk-known-field"), r11);
97
+
98
+ // ── 8. 不相关文件返回 null(不做无谓改写)
99
+ check("普通文件返回 null", redactSensitive("extensions/a.ts", b("const x = 1;")) === null);
100
+ check("settings.json 返回 null", redactSensitive("settings.json", b('{"theme":"dark"}')) === null);
101
+
102
+ // ── 9. 子目录下的 auth.json 同样处理
103
+ const r12 = redact("deep/nested/auth.json", JSON.stringify({ z: "sk-nested-canary-0123456789" }));
104
+ check("嵌套路径 auth.json 也被脱敏", !r12.includes("sk-nested-canary"), r12);
105
+
106
+ // ── 10. 空对象/边界
107
+ check("空对象不报错", (() => { try { redact("auth.json", "{}"); return true; } catch { return false; } })());
108
+ check("null 值不报错", (() => { try { redact("auth.json", '{"a":null,"b":1}'); return true; } catch { return false; } })());
109
+
110
+ // ── 12. 明文审计(防止备份副本把密钥带上云)
111
+ {
112
+ const { auditForPlaintext, looksCredentialFile } = await import("./backup.mjs");
113
+
114
+ // 带有 .bak-* 副本的目录(真实场景:迁移后遗留)
115
+ const leakDir = mkdtempSync(join(tmpdir(), "pi-audit-"));
116
+ mkdirSync(join(leakDir, "extensions"), { recursive: true });
117
+ mkdirSync(join(leakDir, "secrets"), { recursive: true });
118
+
119
+ writeFileSync(
120
+ join(leakDir, "auth.json"),
121
+ JSON.stringify({ deepseek: { type: "api_key", key: "$PI_PI_KEY_DEEPSEEK" } }, null, 2),
122
+ );
123
+ // 审计应看“脱敏后”的内容:这里的引用不会被抹
124
+ const filesOk = [{ rel: "auth.json", size: 100, mtimeMs: 1, abs: join(leakDir, "auth.json") }];
125
+ const auditOk = auditForPlaintext(filesOk, {
126
+ agentDir: leakDir,
127
+ config: { encrypt: false },
128
+ });
129
+ check("审计放过已迁移的 auth.json(引用形式)", auditOk.clean === true, JSON.stringify(auditOk.leaks));
130
+
131
+ // 磁盘上留着明文备份副本:现在前缀匹配会一并脱敏,
132
+ // 所以要断言的是“上传内容不含明文”,而不是“审计报错”。
133
+ writeFileSync(
134
+ join(leakDir, "auth.json.bak-2026-01-01"),
135
+ JSON.stringify({ deepseek: { key: "sk-leaked-in-backup-copy-1234567890" } }),
136
+ );
137
+ const bakRel = "auth.json.bak-2026-01-01";
138
+ const bakFiles = [{ rel: bakRel, size: 100, mtimeMs: 1, abs: join(leakDir, bakRel) }];
139
+ const auditBak = auditForPlaintext(bakFiles, { agentDir: leakDir, config: { encrypt: false } });
140
+ check("审计此时放行(因为会被脱敏)", auditBak.clean === true, JSON.stringify(auditBak.leaks));
141
+
142
+ const { isRedactable } = await import("./backup.mjs");
143
+ check("isRedactable 覆盖 .bak 副本", isRedactable(bakRel) === true);
144
+ const redactedBak = redactSensitive(bakRel, Buffer.from(JSON.stringify({ deepseek: { key: "sk-leaked-in-backup-copy-1234567890" } })), {
145
+ aggressive: true,
146
+ });
147
+ check(
148
+ "auth.json.bak-* 的明文确实被脱敏(关键回归)",
149
+ redactedBak !== null && !redactedBak.toString("utf8").includes("sk-leaked-in-backup-copy"),
150
+ String(redactedBak).slice(0, 100),
151
+ );
152
+
153
+ // 真正无法脱敏的文件(不是 auth 类)仍应被审计拦下
154
+ writeFileSync(join(leakDir, "extensions", "leakyconfig.json"), JSON.stringify({ token: "sk-plaintext-that-audit-must-catch-99" }));
155
+ const auditLeaky = auditForPlaintext(
156
+ [{ rel: "extensions/leakyconfig.json", size: 60, mtimeMs: 1, abs: join(leakDir, "extensions", "leakyconfig.json") }],
157
+ { agentDir: leakDir, config: { encrypt: false } },
158
+ );
159
+ check("审计拦下非凭据文件里的明文", auditLeaky.clean === false, JSON.stringify(auditLeaky));
160
+
161
+ // secrets/ 目录内的东西根本不该进入待上传列表
162
+ writeFileSync(join(leakDir, "secrets", "password.txt"), "plain-password-here");
163
+ check("looksCredentialFile 识别 secrets/", looksCredentialFile("secrets/password.txt") === true);
164
+ check("looksCredentialFile 识别 secrets.dpapi.json", looksCredentialFile("secrets.dpapi.json") === true);
165
+ check("looksCredentialFile 不误判普通扩展", looksCredentialFile("extensions/a.ts") === false);
166
+ const auditSecrets = auditForPlaintext(
167
+ [{ rel: "secrets/password.txt", size: 20, mtimeMs: 1, abs: join(leakDir, "secrets", "password.txt") }],
168
+ { agentDir: leakDir, config: { encrypt: false } },
169
+ );
170
+ check("审计拦下 secrets/ 内文件", auditSecrets.clean === false, JSON.stringify(auditSecrets));
171
+
172
+ // 默认排除规则必须拦住这些
173
+ const { loadConfig } = await import("./config.mjs");
174
+ writeFileSync(
175
+ join(leakDir, "webdav-backup.json"),
176
+ JSON.stringify({ remote: { url: "https://dav.example.com/dav/", username: "u", password: "pw-abcdefgh" } }),
177
+ );
178
+ process.env.PI_CODING_AGENT_DIR = leakDir;
179
+ const lc = loadConfig();
180
+ if (!lc.config) {
181
+ check("审计用配置可加载", false, JSON.stringify(lc.errors));
182
+ }
183
+ const exc = lc.config?.exclude ?? [];
184
+ check("默认排除含 secrets/**", exc.includes("secrets/**"));
185
+ check("默认排除含 secrets.dpapi.json", exc.includes("secrets.dpapi.json"));
186
+ check("默认排除含 *.bak-* 副本", exc.includes("**/*.bak-*"));
187
+
188
+ const collected = collectFiles({ agentDir: leakDir, scope: ["."], exclude: exc });
189
+ const rels2 = collected.files.map((f) => f.rel);
190
+ check("收集时排除 auth.json.bak-*", !rels2.some((r) => r.includes(".bak-")), rels2.join(","));
191
+ check("收集时排除 secrets/ 目录", !rels2.some((r) => r.startsWith("secrets/")), rels2.join(","));
192
+
193
+ // 端到端:未加密备份遇到明文副本必须中止
194
+ const davL = await startDavServer();
195
+ davL.dirs.add("/dav");
196
+ writeFileSync(
197
+ join(leakDir, "webdav-backup.json"),
198
+ JSON.stringify({
199
+ remote: {
200
+ url: `http://127.0.0.1:${davL.port}/dav`,
201
+ username: "user",
202
+ password: "pass",
203
+ remoteDir: "bk",
204
+ remoteName: "pi",
205
+ },
206
+ }),
207
+ );
208
+ process.env.PI_CODING_AGENT_DIR = leakDir;
209
+ const lc2 = loadConfig();
210
+ if (!lc2.config) {
211
+ check("端到端审计配置可加载", false, JSON.stringify(lc2.errors));
212
+ }
213
+ // 手工把一个含明文的文件混进配置扫描范围,验证审计会阻断
214
+ writeFileSync(join(leakDir, "extensions", "leaky.json"), JSON.stringify({ apiKey: "sk-thisistotallyplaintextkey1234" }));
215
+ const blocked = await runBackup({ ...lc2.config, snapshot: false }, {
216
+ log: () => {},
217
+ agentDir: leakDir,
218
+ stateFile: join(leakDir, "s.json"),
219
+ reason: "manual",
220
+ });
221
+ const blockedLeak = blocked.ok === false && Array.isArray(blocked.leaks);
222
+ check("含明文凭据时备份被中止", blockedLeak, JSON.stringify(blocked).slice(0, 300));
223
+ check("中止时未上传任何文件", davL.stats().putCount === 0, `putCount=${davL.stats().putCount}`);
224
+
225
+ // 加密模式应放行(密文上传)
226
+ process.env.PI_CODING_AGENT_DIR = leakDir;
227
+ const encBlocked = await runBackup(
228
+ { ...lc2.config, encrypt: true, encryptKey: "test-key-for-audit-16chars" },
229
+ { log: () => {}, agentDir: leakDir, stateFile: join(leakDir, "s.json"), reason: "manual" },
230
+ );
231
+ check("加密模式下审计不阻断", encBlocked.ok === true, JSON.stringify(encBlocked).slice(0, 200));
232
+
233
+ // 关键端到端:目录里存在 auth.json.bak-* 明文副本时,
234
+ // 未加密备份应仍能完成(因为会被脱敏),且归档里绝不能出现明文。
235
+ rmSync(join(leakDir, "extensions", "leaky.json"), { force: true });
236
+ rmSync(join(leakDir, "extensions", "leakyconfig.json"), { force: true });
237
+ davL.reset();
238
+ const bakRun = await runBackup(
239
+ { ...lc2.config, encrypt: false, remoteName: "pi-bak" },
240
+ { log: () => {}, agentDir: leakDir, stateFile: join(leakDir, "s.json"), reason: "manual" },
241
+ );
242
+ check("含 .bak 副本时未加密备份仍可完成", bakRun.ok === true, JSON.stringify(bakRun).slice(0, 250));
243
+ if (bakRun.ok) {
244
+ const blobRaw = davL.store.get(`/dav/bk/${bakRun.archive}`);
245
+ check("归档已上传", !!blobRaw);
246
+ if (blobRaw) {
247
+ const { gunzipSync } = await import("node:zlib");
248
+ const text = gunzipSync(blobRaw).toString("latin1");
249
+ // 排除规则是第一道防线:.bak 副本根本不进归档。
250
+ // (即使有人删掉该规则,前面的脱敏回归测试保证明文也会被抹掉)
251
+ check("归档不含 .bak 副本(被排除规则挡住)", !text.includes("auth.json.bak-"));
252
+ check(
253
+ "归档中无 .bak 副本明文(关键回归)",
254
+ !text.includes("sk-leaked-in-backup-copy"),
255
+ "明文密钥随备份副本泄露!",
256
+ );
257
+ check("正文 auth.json 仍在归档内", text.includes("auth.json"));
258
+ }
259
+ }
260
+
261
+ davL.server.close();
262
+ try {
263
+ rmSync(leakDir, { recursive: true, force: true });
264
+ } catch {
265
+ /* ignore */
266
+ }
267
+ }
268
+
269
+ console.log(lines.join("\n"));
270
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
271
+ process.exit(fail === 0 ? 0 : 1);
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-restore.mjs — 恢复流程测试
4
+ *
5
+ * 验证:
6
+ * 1. listBackups 能列出归档与快照
7
+ * 2. 归档恢复:解出全部文件且内容一致
8
+ * 3. 加密归档恢复:需要正确密钥
9
+ * 4. 快照恢复:逐文件还原
10
+ * 5. tar 解包拒绝目录穿越
11
+ * 6. 未指定 --to 时落到临时目录,不覆盖原目录
12
+ */
13
+
14
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { startDavServer } from "./dav-mock.mjs";
20
+ import { loadConfig } from "./config.mjs";
21
+ import { runBackup } from "./backup.mjs";
22
+ import { restoreBackup, listBackups } from "./restore.mjs";
23
+ import { extractTar } from "./tar.mjs";
24
+ import { WebDAVClient } from "./webdav.mjs";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+
28
+ let pass = 0;
29
+ let fail = 0;
30
+ const lines = [];
31
+ const check = (n, c, d = "") => {
32
+ if (c) {
33
+ pass++;
34
+ lines.push(` ✅ ${n}`);
35
+ } else {
36
+ fail++;
37
+ lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
38
+ }
39
+ };
40
+
41
+ async function main() {
42
+ console.log("pi WebDAV 备份 — 恢复流程测试\n");
43
+
44
+ const dav = await startDavServer();
45
+ const baseUrl = `http://127.0.0.1:${dav.port}/dav`;
46
+ dav.dirs.add("/dav");
47
+
48
+ const agentDir = mkdtempSync(join(tmpdir(), "pi-restore-test-"));
49
+ mkdirSync(join(agentDir, "extensions", "deep", "nested"), { recursive: true });
50
+ writeFileSync(join(agentDir, "extensions", "one.ts"), "// one\n");
51
+ writeFileSync(join(agentDir, "extensions", "deep", "two.ts"), "// two\n");
52
+ writeFileSync(join(agentDir, "extensions", "deep", "nested", "three.json"), '{"three":true}\n');
53
+ writeFileSync(join(agentDir, "settings.json"), '{"theme":"dark","x":1}\n');
54
+ writeFileSync(join(agentDir, "auth.json"), JSON.stringify({ k: "sk-CANARY-2" }));
55
+ writeFileSync(
56
+ join(agentDir, "webdav-backup.json"),
57
+ JSON.stringify({
58
+ remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
59
+ encrypt: false,
60
+ includeSessions: false,
61
+ }),
62
+ );
63
+
64
+ process.env.PI_CODING_AGENT_DIR = agentDir;
65
+ const loaded = loadConfig();
66
+ check("配置加载", loaded.ok, loaded.errors.join("; "));
67
+ const config = loaded.config;
68
+
69
+ // 1. 先做一个普通归档
70
+ const bk = await runBackup(config, { log: () => {}, agentDir, stateFile: join(agentDir, "state.json") });
71
+ check("归档备份成功", bk.ok === true, bk.error);
72
+
73
+ // 2. listBackups
74
+ const listing = await listBackups(config);
75
+ check("listBackups 列出归档", listing.archives.length === 1, `archives=${listing.archives.length}`);
76
+ check("listBackups 无快照时不报错", listing.snapshot === null);
77
+
78
+ // 3. 恢复到指定目录
79
+ const dest = join(agentDir, "restored-to");
80
+ const r1 = await restoreBackup(config, { agentDir, file: null, to: dest, log: () => {} });
81
+ check("归档恢复成功", r1.ok === true, JSON.stringify(r1));
82
+ check("恢复到指定目录", r1.dest === dest);
83
+ check("恢复出 one.ts", existsSync(join(dest, "extensions", "one.ts")));
84
+ check("恢复出嵌套 three.json", existsSync(join(dest, "extensions", "deep", "nested", "three.json")));
85
+ check("恢复出 settings.json", existsSync(join(dest, "settings.json")));
86
+ check(
87
+ "恢复内容与原始一致",
88
+ readFileSync(join(dest, "extensions", "deep", "nested", "three.json"), "utf8") === '{"three":true}\n',
89
+ );
90
+ check(
91
+ "恢复的 auth.json 仍是脱敏版(不含明文密钥)",
92
+ !readFileSync(join(dest, "auth.json"), "utf8").includes("sk-CANARY-2"),
93
+ );
94
+
95
+ // 4. 加密归档恢复
96
+ const encConfig = { ...config, encrypt: true, encryptKey: "restore-test-secret-key-1", remoteName: "pi-enc" };
97
+ const encBk = await runBackup(encConfig, {
98
+ log: () => {},
99
+ agentDir,
100
+ stateFile: join(agentDir, "state.json"),
101
+ });
102
+ check("加密备份成功", encBk.ok === true, encBk.error);
103
+
104
+ const encDest = join(agentDir, "restored-enc");
105
+ const r2 = await restoreBackup(encConfig, { agentDir, to: encDest, log: () => {} });
106
+ check("加密归档恢复成功", r2.ok === true, JSON.stringify(r2));
107
+ check("加密恢复报告 encrypted=true", r2.encrypted === true);
108
+ check("加密恢复出 one.ts", existsSync(join(encDest, "extensions", "one.ts")));
109
+
110
+ // 错误密钥
111
+ const wrongKeyConfig = { ...encConfig, encryptKey: "totally-wrong-key-value" };
112
+ let wrongOk = true;
113
+ let wrongErr = "";
114
+ try {
115
+ const rw = await restoreBackup(wrongKeyConfig, {
116
+ agentDir,
117
+ to: join(agentDir, "restored-wrong"),
118
+ log: () => {},
119
+ });
120
+ wrongOk = rw.ok;
121
+ wrongErr = String(rw.error ?? "");
122
+ } catch (e) {
123
+ wrongOk = false;
124
+ wrongErr = String(e.message);
125
+ }
126
+ check("错误密钥恢复失败", wrongOk === false, wrongErr);
127
+
128
+ // 无密钥时给出明确提示
129
+ const noKeyConfig = { ...encConfig, encryptKey: null };
130
+ const rn = await restoreBackup(noKeyConfig, { agentDir, to: join(agentDir, "restored-nokey"), log: () => {} });
131
+ check("缺密钥时提示明确", rn.ok === false && /encryptKey|密钥/.test(String(rn.error)), JSON.stringify(rn));
132
+
133
+ // 5. 快照恢复
134
+ const snapConfig = { ...config, snapshot: true, remoteName: "pi-snap" };
135
+ const snapBk = await runBackup(snapConfig, {
136
+ log: () => {},
137
+ agentDir,
138
+ stateFile: join(agentDir, "state.json"),
139
+ });
140
+ check("快照备份成功", snapBk.ok === true, snapBk.error);
141
+
142
+ const snapListing = await listBackups(snapConfig);
143
+ check("listBackups 识别快照", !!snapListing.snapshot, JSON.stringify(snapListing.snapshot));
144
+
145
+ const snapDest = join(agentDir, "restored-snap");
146
+ const r3 = await restoreBackup(snapConfig, { agentDir, file: "pi-snap", to: snapDest, log: () => {} });
147
+ check("快照恢复成功", r3.ok === true, JSON.stringify(r3));
148
+ check("快照恢复出文件", existsSync(join(snapDest, "extensions", "one.ts")));
149
+ check("快照恢复出嵌套文件", existsSync(join(snapDest, "extensions", "deep", "nested", "three.json")));
150
+
151
+ // 6. 恢复不存在的文件
152
+ const r4 = await restoreBackup(config, { agentDir, file: "不存在.tar.gz", to: join(agentDir, "x"), log: () => {} });
153
+ check("指定不存在的备份时失败并列出可选", r4.ok === false && Array.isArray(r4.available));
154
+
155
+ // 7. listOnly
156
+ const r5 = await restoreBackup(config, { agentDir, listOnly: true, log: () => {} });
157
+ check("listOnly 只列不下载", r5.ok === true && r5.listOnly === true && Array.isArray(r5.archives));
158
+
159
+ // 8. tar 目录穿越防护
160
+ const evilDir = mkdtempSync(join(tmpdir(), "pi-evil-"));
161
+ const evilTar = join(evilDir, "evil.tar");
162
+ // 手工构造一个含 ../../evil.txt 的 tar
163
+ {
164
+ const name = Buffer.from("../../evil.txt", "utf8");
165
+ const hdr = Buffer.alloc(512);
166
+ name.copy(hdr, 0);
167
+ hdr.write("0000644\0", 100, 8, "ascii");
168
+ hdr.write("0000000\0", 108, 8, "ascii");
169
+ hdr.write("0000000\0", 116, 8, "ascii");
170
+ hdr.write("00000000005\0", 124, 12, "ascii");
171
+ hdr.write("00000000000\0", 136, 12, "ascii");
172
+ hdr.write(" ", 148, 8, "ascii");
173
+ hdr.write("0", 156, 1, "ascii");
174
+ hdr.write("ustar\0", 257, 6, "ascii");
175
+ hdr.write("00", 263, 2, "ascii");
176
+ let sum = 0;
177
+ for (const b of hdr) sum += b;
178
+ hdr.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "ascii");
179
+ const body = Buffer.alloc(512);
180
+ body.write("PWNED", 0, "utf8");
181
+ const end = Buffer.alloc(1024);
182
+ writeFileSync(evilTar, Buffer.concat([hdr, body, end]));
183
+ }
184
+ let traversalBlocked = false;
185
+ try {
186
+ await extractTar(evilTar, join(evilDir, "out"));
187
+ } catch (e) {
188
+ traversalBlocked = /不安全路径/.test(String(e.message));
189
+ }
190
+ check("tar 解包拒绝目录穿越", traversalBlocked);
191
+ check("穿越文件未被写出", !existsSync(join(tmpdir(), "evil.txt")) && !existsSync(join(evilDir, "evil.txt")));
192
+
193
+ // 9. 未指定 --to 时不动原目录
194
+ const beforeSettings = readFileSync(join(agentDir, "settings.json"), "utf8");
195
+ const r6 = await restoreBackup(config, { agentDir, log: () => {} });
196
+ check("默认恢复到临时目录", r6.ok === true && r6.dest.includes("pi-restore-"), String(r6.dest));
197
+ check("未覆盖原文件", readFileSync(join(agentDir, "settings.json"), "utf8") === beforeSettings);
198
+
199
+ dav.server.close();
200
+ try {
201
+ rmSync(agentDir, { recursive: true, force: true });
202
+ rmSync(evilDir, { recursive: true, force: true });
203
+ if (r6.dest && r6.dest.includes("pi-restore-")) {
204
+ rmSync(dirname(r6.dest), { recursive: true, force: true });
205
+ }
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+
210
+ console.log(lines.join("\n"));
211
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
212
+ process.exit(fail === 0 ? 0 : 1);
213
+ }
214
+
215
+ main().catch((e) => {
216
+ console.log(lines.join("\n"));
217
+ console.error(`\n测试异常:${e?.stack ?? e}`);
218
+ process.exit(1);
219
+ });