pi-webdav-backup 1.0.1 → 1.3.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.
- package/README.md +205 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/args.mjs +27 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -352
- package/extensions/webdav-backup/collect.mjs +104 -2
- package/extensions/webdav-backup/config.mjs +53 -7
- package/extensions/webdav-backup/dav-mock.mjs +166 -145
- package/extensions/webdav-backup/extras.mjs +420 -0
- package/extensions/webdav-backup/json.mjs +33 -0
- package/extensions/webdav-backup/packages.mjs +375 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-empty-cmd.mjs +167 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-json.mjs +89 -0
- package/extensions/webdav-backup/test-plugins.mjs +363 -0
- package/extensions/webdav-backup/test-restore.mjs +286 -219
- package/extensions/webdav-backup/webdav-backup.example.json +4 -9
- package/extensions/webdav-backup/webdav.mjs +258 -218
- package/extensions/webdav-backup.ts +1024 -766
- package/package.json +2 -2
|
@@ -1,219 +1,286 @@
|
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
// 10. 服务端把 GET 302 到另一台主机(对象存储)时,恢复仍要成功
|
|
200
|
+
// —— Cloudreve / 群晖等网盘的真实行为:下载地址是带签名的临时 URL。
|
|
201
|
+
// 回归:客户端曾不跟随重定向,恢复报「下载 xxx 失败:HTTP 302」。
|
|
202
|
+
{
|
|
203
|
+
const objStore = await startDavServer({ requireAuth: false });
|
|
204
|
+
const front = await startDavServer({ redirectGetsTo: `http://127.0.0.1:${objStore.port}` });
|
|
205
|
+
front.dirs.add("/dav");
|
|
206
|
+
try {
|
|
207
|
+
const redirAgentDir = mkdtempSync(join(tmpdir(), "pi-redirect-test-"));
|
|
208
|
+
writeFileSync(join(redirAgentDir, "settings.json"), '{"theme":"nord"}\n');
|
|
209
|
+
writeFileSync(
|
|
210
|
+
join(redirAgentDir, "webdav-backup.json"),
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
remote: {
|
|
213
|
+
url: `http://127.0.0.1:${front.port}/dav`,
|
|
214
|
+
username: "user",
|
|
215
|
+
password: "pass",
|
|
216
|
+
remoteDir: "bk",
|
|
217
|
+
remoteName: "pi-redir",
|
|
218
|
+
},
|
|
219
|
+
encrypt: false,
|
|
220
|
+
includeSessions: false,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
process.env.PI_CODING_AGENT_DIR = redirAgentDir;
|
|
225
|
+
const redirConfig = loadConfig().config;
|
|
226
|
+
|
|
227
|
+
// 备份走的是 WebDAV 本体(PUT 不重定向)
|
|
228
|
+
const rb = await runBackup(redirConfig, {
|
|
229
|
+
log: () => {},
|
|
230
|
+
agentDir: redirAgentDir,
|
|
231
|
+
stateFile: join(redirAgentDir, "state.json"),
|
|
232
|
+
});
|
|
233
|
+
check("重定向场景下备份成功", rb.ok === true, rb.error);
|
|
234
|
+
|
|
235
|
+
// 把归档搬到对象存储侧(模拟网盘内部的 COS 副本);
|
|
236
|
+
// front 的 key 带 url 路径前缀(/dav/bk/...),重定向后路径与之保持一致
|
|
237
|
+
const uploads = [...front.store.keys()].filter((k) => k.includes(`/${redirConfig.remoteDir}/`));
|
|
238
|
+
check("远端有归档文件", uploads.length > 0, JSON.stringify([...front.store.keys()]));
|
|
239
|
+
for (const k of uploads) objStore.store.set(k, front.store.get(k));
|
|
240
|
+
|
|
241
|
+
const redirDest = join(redirAgentDir, "restored-redirect");
|
|
242
|
+
let rr = null;
|
|
243
|
+
let err = "";
|
|
244
|
+
try {
|
|
245
|
+
rr = await restoreBackup(redirConfig, { agentDir: redirAgentDir, to: redirDest, log: () => {} });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
err = String(e.message);
|
|
248
|
+
}
|
|
249
|
+
check("302 重定向后仍能恢复", rr?.ok === true, err || JSON.stringify(rr?.error));
|
|
250
|
+
check("重定向恢复出 settings.json", existsSync(join(redirDest, "settings.json")));
|
|
251
|
+
check(
|
|
252
|
+
"重定向恢复内容一致",
|
|
253
|
+
existsSync(join(redirDest, "settings.json")) &&
|
|
254
|
+
readFileSync(join(redirDest, "settings.json"), "utf8") === '{"theme":"nord"}\n',
|
|
255
|
+
);
|
|
256
|
+
// 跨主机重定向时不能把 WebDAV 凭据带给对象存储
|
|
257
|
+
check("不向重定向目标泄露凭据", objStore.lastAuth === null, String(objStore.lastAuth));
|
|
258
|
+
|
|
259
|
+
rmSync(redirAgentDir, { recursive: true, force: true });
|
|
260
|
+
} finally {
|
|
261
|
+
front.server.close();
|
|
262
|
+
objStore.server.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
dav.server.close();
|
|
267
|
+
try {
|
|
268
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
269
|
+
rmSync(evilDir, { recursive: true, force: true });
|
|
270
|
+
if (r6.dest && r6.dest.includes("pi-restore-")) {
|
|
271
|
+
rmSync(dirname(r6.dest), { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(lines.join("\n"));
|
|
278
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
279
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
main().catch((e) => {
|
|
283
|
+
console.log(lines.join("\n"));
|
|
284
|
+
console.error(`\n测试异常:${e?.stack ?? e}`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|
|
@@ -1,37 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_说明": "示例配置。复制到 ~/.pi/agent/webdav-backup.json 后按需修改,或直接运行 /backup-setup 用向导生成。",
|
|
3
|
-
|
|
4
3
|
"enabled": true,
|
|
5
|
-
|
|
6
4
|
"remote": {
|
|
7
5
|
"_url_坚果云": "https://dav.jianguoyun.com/dav/",
|
|
8
6
|
"_url_Nextcloud": "https://your-cloud.example.com/remote.php/dav/files/<用户名>/",
|
|
9
7
|
"_url_群晖WebDAV": "https://your-nas.example.com:5006/pi-backup",
|
|
10
8
|
"_url_自建rclone": "http://127.0.0.1:8080/pi-backup",
|
|
11
9
|
"url": "https://dav.jianguoyun.com/dav/",
|
|
12
|
-
|
|
13
10
|
"_username_说明": "坚果云填注册邮箱;Nextcloud 填用户名",
|
|
14
11
|
"username": "you@example.com",
|
|
15
|
-
|
|
16
12
|
"_password_说明": "密钥引用,推荐 dpapi:webdav-password(先用 /backup keys set webdav-password 创建)。也可用 $ENV_VAR 或 file:路径;留空则读环境变量 PI_WEBDAV_BACKUP_PASSWORD",
|
|
17
13
|
"password": "dpapi:webdav-password",
|
|
18
|
-
|
|
19
14
|
"remoteDir": "pi-backup",
|
|
20
15
|
"remoteName": "pi-agent"
|
|
21
16
|
},
|
|
22
|
-
|
|
23
17
|
"scope": ["."],
|
|
24
18
|
"exclude": [],
|
|
19
|
+
"_extraScope_说明": "agentDir 之外的额外备份路径(绝对路径或 ~/...),例如 ~/.pi/settings.json、~/.pi/web-search.json、~/.config/rpiv-todo;归档内落在 extra/ 前缀下,恢复时按当前机器的家目录/XDG 锚点重映射(支持换用户名/盘符/平台),可用 /backup apply-extras 归位",
|
|
20
|
+
"extraScope": [],
|
|
21
|
+
"_includePlugins_说明": "是否把插件实现代码(node_modules)一并备份;true=换机恢复后插件直接可用(推荐,体积较大),false=瘦备份、恢复后需 /backup repair-plugins 联网补装",
|
|
22
|
+
"includePlugins": false,
|
|
25
23
|
"includeSessions": false,
|
|
26
24
|
"snapshot": false,
|
|
27
|
-
|
|
28
25
|
"encrypt": false,
|
|
29
26
|
"encryptKey": "dpapi:backup-key",
|
|
30
|
-
|
|
31
27
|
"backupOnExit": true,
|
|
32
28
|
"backupOnExitMinIntervalMinutes": 30,
|
|
33
29
|
"backupOnExitTimeoutMs": 600000,
|
|
34
|
-
|
|
35
30
|
"keepVersions": 10,
|
|
36
31
|
"timeoutMs": 60000,
|
|
37
32
|
"insecureTls": false
|