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.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/extensions/webdav-backup/audit-real.mjs +84 -0
- package/extensions/webdav-backup/auth-migrate.mjs +242 -0
- package/extensions/webdav-backup/backup.mjs +684 -0
- package/extensions/webdav-backup/cli.mjs +352 -0
- package/extensions/webdav-backup/collect.mjs +125 -0
- package/extensions/webdav-backup/config-paths.mjs +32 -0
- package/extensions/webdav-backup/config.mjs +199 -0
- package/extensions/webdav-backup/cron.mjs +90 -0
- package/extensions/webdav-backup/crypto.mjs +163 -0
- package/extensions/webdav-backup/dav-mock.mjs +145 -0
- package/extensions/webdav-backup/migrate.mjs +167 -0
- package/extensions/webdav-backup/restore.mjs +220 -0
- package/extensions/webdav-backup/run-tests.mjs +41 -0
- package/extensions/webdav-backup/secrets.mjs +404 -0
- package/extensions/webdav-backup/tar.mjs +111 -0
- package/extensions/webdav-backup/test-auth-migrate.mjs +187 -0
- package/extensions/webdav-backup/test-cli.mjs +331 -0
- package/extensions/webdav-backup/test-redact.mjs +271 -0
- package/extensions/webdav-backup/test-restore.mjs +219 -0
- package/extensions/webdav-backup/test-secrets.mjs +268 -0
- package/extensions/webdav-backup/test.mjs +442 -0
- package/extensions/webdav-backup/verify-load.mjs +89 -0
- package/extensions/webdav-backup/webdav-backup.example.json +38 -0
- package/extensions/webdav-backup/webdav.mjs +218 -0
- package/extensions/webdav-backup.ts +766 -0
- package/package.json +41 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-auth-migrate.mjs — auth.json 明文密钥迁移测试
|
|
4
|
+
*
|
|
5
|
+
* 关键不变量:
|
|
6
|
+
* - 空 key(pi 用它表示“未配置”)绝不能改成引用
|
|
7
|
+
* - 已是引用的不重复迁移
|
|
8
|
+
* - 迁移后文件里不再有明文,且引用可被解析出原值
|
|
9
|
+
* - 写环境变量失败时不动文件(避免丢密钥)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
let pass = 0;
|
|
17
|
+
let fail = 0;
|
|
18
|
+
const lines = [];
|
|
19
|
+
const check = (n, c, d = "") => {
|
|
20
|
+
if (c) {
|
|
21
|
+
pass++;
|
|
22
|
+
lines.push(` ✅ ${n}`);
|
|
23
|
+
} else {
|
|
24
|
+
fail++;
|
|
25
|
+
lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-mig-"));
|
|
30
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
31
|
+
|
|
32
|
+
const A = await import("./auth-migrate.mjs");
|
|
33
|
+
const S = await import("./secrets.mjs");
|
|
34
|
+
|
|
35
|
+
const authPath = join(agentDir, "auth.json");
|
|
36
|
+
const write = (obj) => writeFileSync(authPath, JSON.stringify(obj, null, 2));
|
|
37
|
+
|
|
38
|
+
console.log("pi WebDAV 备份 — auth.json 迁移测试\n");
|
|
39
|
+
|
|
40
|
+
// ── 1. 环境变量命名
|
|
41
|
+
check("provider 名转为合法环境变量名", A.envVarNameFor("deepseek") === "PI_PI_KEY_DEEPSEEK");
|
|
42
|
+
check("含连字符的 provider", A.envVarNameFor("azure-openai-responses") === "PI_PI_KEY_AZURE_OPENAI_RESPONSES");
|
|
43
|
+
check("含点的 provider", A.envVarNameFor("foo.bar") === "PI_PI_KEY_FOO_BAR");
|
|
44
|
+
check("空名兜底", A.envVarNameFor("") === "PI_PI_KEY_");
|
|
45
|
+
|
|
46
|
+
// ── 2. 扫描
|
|
47
|
+
write({
|
|
48
|
+
deepseek: { type: "api_key", key: "sk-" + "a".repeat(32) },
|
|
49
|
+
"azure-openai-responses": { type: "api_key", key: "" },
|
|
50
|
+
migrated: { type: "api_key", key: "$ALREADY_SET" },
|
|
51
|
+
cmdbased: { type: "api_key", key: "!pass show foo" },
|
|
52
|
+
dpapied: { type: "api_key", key: "dpapi:some-name" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const scan = A.scanAuthSecrets();
|
|
56
|
+
check("扫描成功", scan.ok === true, JSON.stringify(scan));
|
|
57
|
+
const byProvider = Object.fromEntries(scan.items.map((i) => [i.provider, i]));
|
|
58
|
+
check("识别 deepseek 为明文", byProvider.deepseek?.kind === "plaintext");
|
|
59
|
+
check("识别空 key 为 empty", byProvider["azure-openai-responses"]?.kind === "empty");
|
|
60
|
+
check("识别 $VAR 为引用", byProvider.migrated?.kind === "reference");
|
|
61
|
+
check("识别 !命令 为引用", byProvider.cmdbased?.kind === "reference");
|
|
62
|
+
check("识别 dpapi: 为引用", byProvider.dpapied?.kind === "reference");
|
|
63
|
+
check("明文项提供建议环境变量名", byProvider.deepseek?.suggestedEnv === "PI_PI_KEY_DEEPSEEK");
|
|
64
|
+
check("明文项提供建议 dpapi 名", byProvider.deepseek?.suggestedDpapi === "dpapi:auth-deepseek");
|
|
65
|
+
check("扫描结果不含密钥明文", !JSON.stringify(scan).includes("sk-aaaa"));
|
|
66
|
+
|
|
67
|
+
// ── 3. dry-run 不改文件
|
|
68
|
+
const before = readFileSync(authPath, "utf8");
|
|
69
|
+
const dry = await A.migrateAuthJson({ method: "env", log: () => {}, dryRun: true });
|
|
70
|
+
check("dry-run 报告待迁移项", dry.ok && dry.wouldMigrate?.length === 1, JSON.stringify(dry));
|
|
71
|
+
check("dry-run 指定目标为 $ENV", dry.wouldMigrate?.[0]?.target === "$PI_PI_KEY_DEEPSEEK");
|
|
72
|
+
check("dry-run 未修改文件", readFileSync(authPath, "utf8") === before);
|
|
73
|
+
|
|
74
|
+
// ── 4. 真实迁移(env)
|
|
75
|
+
if (S.dpapiAvailable()) {
|
|
76
|
+
const real = await A.migrateAuthJson({ method: "env", log: () => {} });
|
|
77
|
+
check("迁移成功", real.ok === true, JSON.stringify(real));
|
|
78
|
+
check("迁移了 1 项", real.migrated === 1, `migrated=${real.migrated}`);
|
|
79
|
+
check("迁移前备份 auth.json", !!real.backup && existsSync(real.backup));
|
|
80
|
+
check("待重启环境变量已列出", real.envVarsNeedRestart?.includes("PI_PI_KEY_DEEPSEEK"));
|
|
81
|
+
|
|
82
|
+
const afterObj = JSON.parse(readFileSync(authPath, "utf8"));
|
|
83
|
+
check("deepseek.key 已变为 $ENV 引用", afterObj.deepseek.key === "$PI_PI_KEY_DEEPSEEK", afterObj.deepseek.key);
|
|
84
|
+
check(
|
|
85
|
+
"auth.json 不再含明文密钥",
|
|
86
|
+
!readFileSync(authPath, "utf8").includes("sk-aaaa"),
|
|
87
|
+
"明文仍存在",
|
|
88
|
+
);
|
|
89
|
+
check("空 key 保持为空(未被破坏)", afterObj["azure-openai-responses"].key === "", JSON.stringify(afterObj["azure-openai-responses"]));
|
|
90
|
+
check("已有引用未被改写", afterObj.migrated.key === "$ALREADY_SET");
|
|
91
|
+
check("!命令引用未被改写", afterObj.cmdbased.key === "!pass show foo");
|
|
92
|
+
check("dpapi 引用未被改写", afterObj.dpapied.key === "dpapi:some-name");
|
|
93
|
+
check("type 字段保持", afterObj.deepseek.type === "api_key");
|
|
94
|
+
|
|
95
|
+
// 迁移后的引用必须能解析出原值
|
|
96
|
+
const resolved = S.resolveSecret(afterObj.deepseek.key);
|
|
97
|
+
check("迁移后的引用可解析出原密钥", resolved.value === "sk-" + "a".repeat(32), JSON.stringify({ source: resolved.source, len: resolved.value?.length }));
|
|
98
|
+
|
|
99
|
+
// 备份文件应保留原明文(用于回滚)
|
|
100
|
+
check("备份文件保留了可回滚的原文", readFileSync(real.backup, "utf8").includes("sk-aaaa"));
|
|
101
|
+
|
|
102
|
+
// 重复迁移无副作用
|
|
103
|
+
const again = await A.migrateAuthJson({ method: "env", log: () => {} });
|
|
104
|
+
check("重复迁移为 0 项", again.ok === true && again.message !== undefined, JSON.stringify(again));
|
|
105
|
+
|
|
106
|
+
// ── 5. dpapi 方式
|
|
107
|
+
write({
|
|
108
|
+
provider1: { type: "api_key", key: "secret-key-one-1234567890" },
|
|
109
|
+
provider2: { type: "api_key", key: "secret-key-two-0987654321" },
|
|
110
|
+
});
|
|
111
|
+
const dp = await A.migrateAuthJson({ method: "dpapi", log: () => {} });
|
|
112
|
+
check("dpapi 迁移成功", dp.ok === true, JSON.stringify(dp));
|
|
113
|
+
check("dpapi 迁移 2 项", dp.migrated === 2, `migrated=${dp.migrated}`);
|
|
114
|
+
const dpObj = JSON.parse(readFileSync(authPath, "utf8"));
|
|
115
|
+
check("provider1 → dpapi 引用", dpObj.provider1.key === "dpapi:auth-provider1", dpObj.provider1.key);
|
|
116
|
+
check("dpapi 迁移后无明文", !readFileSync(authPath, "utf8").includes("secret-key-one"));
|
|
117
|
+
check("dpapi 引用可解出原值", S.resolveSecret(dpObj.provider1.key).value === "secret-key-one-1234567890");
|
|
118
|
+
|
|
119
|
+
// ── 6. perProvider 覆盖:部分保留
|
|
120
|
+
write({
|
|
121
|
+
keepme: { type: "api_key", key: "keep-this-plaintext-key-1234" },
|
|
122
|
+
moveme: { type: "api_key", key: "move-this-plaintext-key-5678" },
|
|
123
|
+
});
|
|
124
|
+
const mixed = await A.migrateAuthJson({
|
|
125
|
+
method: "dpapi",
|
|
126
|
+
perProvider: { keepme: "keep", moveme: "env" },
|
|
127
|
+
log: () => {},
|
|
128
|
+
});
|
|
129
|
+
check("混合迁移成功", mixed.ok === false || mixed.migrated === 1, JSON.stringify(mixed));
|
|
130
|
+
const mixedObj = JSON.parse(readFileSync(authPath, "utf8"));
|
|
131
|
+
check("keepme 保持明文(用户选择)", mixedObj.keepme.key === "keep-this-plaintext-key-1234");
|
|
132
|
+
check("moveme 迁移为引用", mixedObj.moveme.key.startsWith("$"), mixedObj.moveme.key);
|
|
133
|
+
check("剩余明文被报告", mixed.remainingPlaintext?.includes("keepme"), JSON.stringify(mixed.remainingPlaintext));
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(" ⏭ 跳过真实迁移(非 Windows)");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 7. 空 auth.json / 无明文
|
|
139
|
+
write({});
|
|
140
|
+
const none = await A.migrateAuthJson({ method: "env", log: () => {} });
|
|
141
|
+
check("无条目时报告无需迁移", none.ok === true && none.migrated === 0, JSON.stringify(none));
|
|
142
|
+
|
|
143
|
+
write({ p: { type: "api_key", key: "" } });
|
|
144
|
+
const onlyEmpty = await A.migrateAuthJson({ method: "env", log: () => {} });
|
|
145
|
+
check("仅空 key 时不动文件", onlyEmpty.ok === true && onlyEmpty.migrated === 0, JSON.stringify(onlyEmpty));
|
|
146
|
+
check("空 key 仍为空", JSON.parse(readFileSync(authPath, "utf8")).p.key === "");
|
|
147
|
+
|
|
148
|
+
// ── 8. 损坏的 auth.json
|
|
149
|
+
writeFileSync(authPath, "{ broken json");
|
|
150
|
+
const broken = A.scanAuthSecrets();
|
|
151
|
+
check("损坏 JSON 被识别", broken.ok === false && /不是合法 JSON/.test(broken.error), JSON.stringify(broken));
|
|
152
|
+
const brokenMig = await A.migrateAuthJson({ method: "env", log: () => {} });
|
|
153
|
+
check("损坏 JSON 时迁移被拒绝", brokenMig.ok === false);
|
|
154
|
+
|
|
155
|
+
// ── 9. 不存在的 auth.json
|
|
156
|
+
rmSync(authPath, { force: true });
|
|
157
|
+
const missing = A.scanAuthSecrets();
|
|
158
|
+
check("缺失 auth.json 被识别", missing.ok === false && /不存在/.test(missing.error), JSON.stringify(missing));
|
|
159
|
+
|
|
160
|
+
// ── 10. 脱敏保留引用(回归:引用被抹掉会丢失“密钥在哪”的信息)
|
|
161
|
+
const { redactSensitive } = await import("./backup.mjs");
|
|
162
|
+
const refAuth = JSON.stringify({
|
|
163
|
+
deepseek: { type: "api_key", key: "$PI_PI_KEY_DEEPSEEK" },
|
|
164
|
+
other: { type: "api_key", key: "sk-realm-plaintext-secret" },
|
|
165
|
+
empty: { type: "api_key", key: "" },
|
|
166
|
+
cmd: { type: "api_key", key: "!pass show x" },
|
|
167
|
+
dp: { type: "api_key", key: "dpapi:auth-x" },
|
|
168
|
+
});
|
|
169
|
+
const red = redactSensitive("auth.json", Buffer.from(refAuth), { aggressive: true }).toString("utf8");
|
|
170
|
+
const redObj = JSON.parse(red);
|
|
171
|
+
check("脱敏保留 $ENV 引用", redObj.deepseek.key === "$PI_PI_KEY_DEEPSEEK", redObj.deepseek.key);
|
|
172
|
+
check("脱敏保留 !命令 引用", redObj.cmd.key === "!pass show x");
|
|
173
|
+
check("脱敏保留 dpapi 引用", redObj.dp.key === "dpapi:auth-x");
|
|
174
|
+
check("脱敏保留空 key", redObj.empty.key === "");
|
|
175
|
+
check("脱敏替换真明文", redObj.other.key === "__REDACTED__", redObj.other.key);
|
|
176
|
+
check("脱敏后不含真明文", !red.includes("sk-realm-plaintext-secret"));
|
|
177
|
+
|
|
178
|
+
// 清理
|
|
179
|
+
try {
|
|
180
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
181
|
+
} catch {
|
|
182
|
+
/* ignore */
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(lines.join("\n"));
|
|
186
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
187
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-cli.mjs — CLI 子进程集成测试
|
|
4
|
+
*
|
|
5
|
+
* 与 test.mjs 互补:这里通过 spawn 真实调用 cli.mjs,
|
|
6
|
+
* 验证扩展实际使用的代码路径(含退出备份那套参数)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node test-cli.mjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { gunzipSync } from "node:zlib";
|
|
17
|
+
|
|
18
|
+
import { startDavServer } from "./dav-mock.mjs";
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const CLI = join(here, "cli.mjs");
|
|
22
|
+
|
|
23
|
+
let pass = 0;
|
|
24
|
+
let fail = 0;
|
|
25
|
+
const lines = [];
|
|
26
|
+
const check = (name, cond, detail = "") => {
|
|
27
|
+
if (cond) {
|
|
28
|
+
pass++;
|
|
29
|
+
lines.push(` ✅ ${name}`);
|
|
30
|
+
} else {
|
|
31
|
+
fail++;
|
|
32
|
+
lines.push(` ❌ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function runCli(args, env) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
39
|
+
env: { ...process.env, ...env },
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
let stdout = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
|
45
|
+
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
|
46
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lastJson = (out) => {
|
|
51
|
+
const line = out.trim().split("\n").filter((l) => l.trim().startsWith("{")).pop();
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(line ?? "{}");
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
console.log("pi WebDAV 备份 — CLI 集成测试\n");
|
|
61
|
+
|
|
62
|
+
const dav = await startDavServer();
|
|
63
|
+
const baseUrl = `http://127.0.0.1:${dav.port}/dav`;
|
|
64
|
+
dav.dirs.add("/dav");
|
|
65
|
+
|
|
66
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-cli-test-"));
|
|
67
|
+
mkdirSync(join(agentDir, "extensions", "sub"), { recursive: true });
|
|
68
|
+
writeFileSync(join(agentDir, "extensions", "a.ts"), "export default () => {};\n");
|
|
69
|
+
writeFileSync(join(agentDir, "extensions", "sub", "b.md"), "# hello\n");
|
|
70
|
+
writeFileSync(join(agentDir, "settings.json"), '{"theme":"dark"}\n');
|
|
71
|
+
writeFileSync(join(agentDir, "auth.json"), JSON.stringify({ p: { key: "sk-LEAK-CANARY" } }));
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(agentDir, "webdav-backup.json"),
|
|
74
|
+
JSON.stringify(
|
|
75
|
+
{
|
|
76
|
+
enabled: true,
|
|
77
|
+
remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
|
|
78
|
+
scope: ["."],
|
|
79
|
+
includeSessions: false,
|
|
80
|
+
encrypt: false,
|
|
81
|
+
keepVersions: 3,
|
|
82
|
+
backupOnExit: true,
|
|
83
|
+
backupOnExitMinIntervalMinutes: 0,
|
|
84
|
+
},
|
|
85
|
+
null,
|
|
86
|
+
2,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
const env = { PI_CODING_AGENT_DIR: agentDir };
|
|
90
|
+
// 父进程也要指向同一个 agentDir,否则 setDpapiSecret 会写到真实 ~/.pi/agent
|
|
91
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
92
|
+
|
|
93
|
+
// 1. check
|
|
94
|
+
const chk = await runCli(["check", "--json"], env);
|
|
95
|
+
const chkJson = lastJson(chk.stdout);
|
|
96
|
+
check("check 退出码 0", chk.code === 0, `code=${chk.code} ${chk.stderr}`);
|
|
97
|
+
check("check 认证通过", chkJson.ok === true, JSON.stringify(chkJson));
|
|
98
|
+
|
|
99
|
+
// 2. 错误密码
|
|
100
|
+
writeFileSync(
|
|
101
|
+
join(agentDir, "webdav-backup.json"),
|
|
102
|
+
JSON.stringify(
|
|
103
|
+
{ remote: { url: baseUrl, username: "user", password: "nope", remoteDir: "bk", remoteName: "pi" } },
|
|
104
|
+
null,
|
|
105
|
+
2,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
const bad = await runCli(["check", "--json"], env);
|
|
109
|
+
const badJson = lastJson(bad.stdout);
|
|
110
|
+
check("错误密码退出码非 0", bad.code !== 0);
|
|
111
|
+
check("错误密码提示 401", /401|认证/.test(String(badJson.error)), JSON.stringify(badJson));
|
|
112
|
+
|
|
113
|
+
// 3. 正常配置下的备份(模拟扩展退出时的调用)
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(agentDir, "webdav-backup.json"),
|
|
116
|
+
JSON.stringify(
|
|
117
|
+
{
|
|
118
|
+
enabled: true,
|
|
119
|
+
remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
|
|
120
|
+
scope: ["."],
|
|
121
|
+
includeSessions: false,
|
|
122
|
+
encrypt: false,
|
|
123
|
+
keepVersions: 3,
|
|
124
|
+
backupOnExit: true,
|
|
125
|
+
backupOnExitMinIntervalMinutes: 0,
|
|
126
|
+
},
|
|
127
|
+
null,
|
|
128
|
+
2,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const bk = await runCli(["backup", "--reason", "exit", "--json"], env);
|
|
132
|
+
const bkJson = lastJson(bk.stdout);
|
|
133
|
+
check("退出备份退出码 0", bk.code === 0, `code=${bk.code} ${bk.stderr}`);
|
|
134
|
+
check("退出备份成功", bkJson.ok === true, JSON.stringify(bkJson));
|
|
135
|
+
check("退出备份有远端路径", typeof bkJson.remotePath === "string", JSON.stringify(bkJson));
|
|
136
|
+
check("退出备份已脱敏 auth.json", Number(bkJson.redacted) > 0, `redacted=${bkJson.redacted}`);
|
|
137
|
+
|
|
138
|
+
// 4. 归档内容确实脱敏 + 排除生效
|
|
139
|
+
const archiveName = String(bkJson.archive);
|
|
140
|
+
const blob = dav.store.get(`/dav/bk/${archiveName}`);
|
|
141
|
+
check("远端存在该归档", !!blob);
|
|
142
|
+
if (blob) {
|
|
143
|
+
const text = gunzipSync(blob).toString("latin1");
|
|
144
|
+
check("归档不含明文密钥", !text.includes("sk-LEAK-CANARY"), "密钥泄漏!");
|
|
145
|
+
check("归档含 extensions/a.ts", text.includes("extensions/a.ts"));
|
|
146
|
+
check("归档含嵌套文件", text.includes("extensions/sub/b.md"));
|
|
147
|
+
check("归档写入备份者自身配置", text.includes("webdav-backup.json"));
|
|
148
|
+
check("归档中配置密码被脱敏", !text.includes("\"password\": \"pass\""), "配置密码泄漏!");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 5. status
|
|
152
|
+
const st = await runCli(["status", "--json"], env);
|
|
153
|
+
const stJson = lastJson(st.stdout);
|
|
154
|
+
check("status 返回 lastBackupAt", typeof stJson.state?.lastBackupAt === "string", JSON.stringify(stJson));
|
|
155
|
+
|
|
156
|
+
// 6. list
|
|
157
|
+
const ls = await runCli(["list", "--json"], env);
|
|
158
|
+
const lsJson = lastJson(ls.stdout);
|
|
159
|
+
check("list 至少返回 1 个文件", Array.isArray(lsJson.files) && lsJson.files.length >= 1);
|
|
160
|
+
|
|
161
|
+
// 7. prune 只保留 1 个
|
|
162
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
163
|
+
await runCli(["backup", "--reason", "manual", "--json"], env);
|
|
164
|
+
const pr = await runCli(["prune", "--keep", "1", "--json"], env);
|
|
165
|
+
const prJson = lastJson(pr.stdout);
|
|
166
|
+
check("prune 执行成功", prJson.ok === true, JSON.stringify(prJson));
|
|
167
|
+
check("prune 保留了 1 个", prJson.kept === 1, `kept=${prJson.kept}`);
|
|
168
|
+
|
|
169
|
+
// 8. dry-run 不写远端
|
|
170
|
+
const beforePut = dav.stats().putCount;
|
|
171
|
+
const dry = await runCli(["backup", "--dry-run", "--json"], env);
|
|
172
|
+
const dryJson = lastJson(dry.stdout);
|
|
173
|
+
check("dry-run 成功", dryJson.ok === true && dryJson.dryRun === true, JSON.stringify(dryJson));
|
|
174
|
+
check("dry-run 未上传", dav.stats().putCount === beforePut);
|
|
175
|
+
|
|
176
|
+
// 9. 缺配置时的错误处理
|
|
177
|
+
const emptyDir = mkdtempSync(join(tmpdir(), "pi-cli-empty-"));
|
|
178
|
+
const miss = await runCli(["backup", "--json"], { PI_CODING_AGENT_DIR: emptyDir });
|
|
179
|
+
const missJson = lastJson(miss.stdout);
|
|
180
|
+
check("缺配置时退出码非 0", miss.code !== 0);
|
|
181
|
+
check("缺配置时给出明确错误", /配置文件不存在/.test(String(missJson.error)), JSON.stringify(missJson));
|
|
182
|
+
|
|
183
|
+
// 10. 非法 URL 配置
|
|
184
|
+
writeFileSync(
|
|
185
|
+
join(emptyDir, "webdav-backup.json"),
|
|
186
|
+
JSON.stringify({ remote: { url: "ftp://x/y", username: "u", password: "p" } }, null, 2),
|
|
187
|
+
);
|
|
188
|
+
const badUrl = await runCli(["backup", "--json"], { PI_CODING_AGENT_DIR: emptyDir });
|
|
189
|
+
const badUrlJson = lastJson(badUrl.stdout);
|
|
190
|
+
check("非法协议被拒绝", badUrl.code !== 0 && /http/.test(String(badUrlJson.error)), JSON.stringify(badUrlJson));
|
|
191
|
+
|
|
192
|
+
// 11. 日志文件确实写入
|
|
193
|
+
const logPath = join(agentDir, "webdav-backup.log");
|
|
194
|
+
check("日志文件已生成", existsSync(logPath));
|
|
195
|
+
if (existsSync(logPath)) {
|
|
196
|
+
const log = readFileSync(logPath, "utf8");
|
|
197
|
+
check("日志记录了开始备份", /开始备份/.test(log));
|
|
198
|
+
check("日志记录了完成", /完成|上传中/.test(log));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 12. 加密模式经 CLI 全流程,并用库解密回读
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(agentDir, "webdav-backup.json"),
|
|
204
|
+
JSON.stringify(
|
|
205
|
+
{
|
|
206
|
+
remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi-enc" },
|
|
207
|
+
encrypt: true,
|
|
208
|
+
encryptKey: "correct-horse-battery-staple",
|
|
209
|
+
},
|
|
210
|
+
null,
|
|
211
|
+
2,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
const encRun = await runCli(["backup", "--reason", "manual", "--json"], env);
|
|
215
|
+
const encJson = lastJson(encRun.stdout);
|
|
216
|
+
check("加密备份成功", encJson.ok === true, JSON.stringify(encJson));
|
|
217
|
+
check("加密归档后缀 .pibak", String(encJson.archive).endsWith(".pibak"), String(encJson.archive));
|
|
218
|
+
|
|
219
|
+
const encBlob = dav.store.get(`/dav/bk/${encJson.archive}`);
|
|
220
|
+
if (encBlob) {
|
|
221
|
+
const { decryptFile } = await import("./crypto.mjs");
|
|
222
|
+
const encTmp = join(agentDir, "rt.pibak");
|
|
223
|
+
const outTmp = join(agentDir, "rt.tar.gz");
|
|
224
|
+
writeFileSync(encTmp, encBlob);
|
|
225
|
+
await decryptFile(encTmp, outTmp, "correct-horse-battery-staple");
|
|
226
|
+
const plain = gunzipSync(readFileSync(outTmp)).toString("latin1");
|
|
227
|
+
check("CLI 加密归档可解密回读", plain.includes("extensions/a.ts"));
|
|
228
|
+
check("解密后仍无明文密钥", !plain.includes("sk-LEAK-CANARY"));
|
|
229
|
+
|
|
230
|
+
let wrongOk = false;
|
|
231
|
+
try {
|
|
232
|
+
await decryptFile(encTmp, join(agentDir, "rt2.tar.gz"), "wrong-key-wrong-key");
|
|
233
|
+
wrongOk = true;
|
|
234
|
+
} catch {
|
|
235
|
+
wrongOk = false;
|
|
236
|
+
}
|
|
237
|
+
check("错误密钥解密被拒绝", wrongOk === false);
|
|
238
|
+
} else {
|
|
239
|
+
check("CLI 加密归档存在", false, "未找到加密归档");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 13. DPAPI 引用端到端(验证子进程可解密 → 实际生产路径)
|
|
243
|
+
const { setDpapiSecret, dpapiAvailable, deleteDpapiSecret } = await import("./secrets.mjs");
|
|
244
|
+
if (dpapiAvailable()) {
|
|
245
|
+
setDpapiSecret("cli-e2e-password", "pass");
|
|
246
|
+
writeFileSync(
|
|
247
|
+
join(agentDir, "webdav-backup.json"),
|
|
248
|
+
JSON.stringify(
|
|
249
|
+
{
|
|
250
|
+
remote: {
|
|
251
|
+
url: baseUrl,
|
|
252
|
+
username: "user",
|
|
253
|
+
password: "dpapi:cli-e2e-password",
|
|
254
|
+
remoteDir: "bk",
|
|
255
|
+
remoteName: "pi-dpapi",
|
|
256
|
+
},
|
|
257
|
+
keepVersions: 2,
|
|
258
|
+
},
|
|
259
|
+
null,
|
|
260
|
+
2,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
check("配置文件不含明文密码", !readFileSync(join(agentDir, "webdav-backup.json"), "utf8").includes('"pass"'));
|
|
264
|
+
|
|
265
|
+
const dpChk = await runCli(["check", "--json"], env);
|
|
266
|
+
check("DPAPI 密码可通过子进程认证", lastJson(dpChk.stdout).ok === true, dpChk.stdout.trim());
|
|
267
|
+
|
|
268
|
+
const dpBk = await runCli(["backup", "--reason", "exit", "--json"], env);
|
|
269
|
+
check("DPAPI 密码可完成退出备份", lastJson(dpBk.stdout).ok === true, lastJson(dpBk.stdout).error ?? "");
|
|
270
|
+
|
|
271
|
+
// 凭证错误时应报认证失败(而不是谜之错误)
|
|
272
|
+
setDpapiSecret("cli-e2e-password", "wrong-password");
|
|
273
|
+
const dpBad = await runCli(["check", "--json"], env);
|
|
274
|
+
const dpBadJson = lastJson(dpBad.stdout);
|
|
275
|
+
check("DPAPI 错误密码报 401", dpBad.code !== 0 && /401|认证/.test(String(dpBadJson.error)), JSON.stringify(dpBadJson));
|
|
276
|
+
|
|
277
|
+
// 引用不存在的密钥时应阻断而非静默空密码
|
|
278
|
+
writeFileSync(
|
|
279
|
+
join(agentDir, "webdav-backup.json"),
|
|
280
|
+
JSON.stringify(
|
|
281
|
+
{ remote: { url: baseUrl, username: "user", password: "dpapi:no-such-entry", remoteDir: "bk" } },
|
|
282
|
+
null,
|
|
283
|
+
2,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
const dpMiss = await runCli(["backup", "--json"], env);
|
|
287
|
+
const dpMissJson = lastJson(dpMiss.stdout);
|
|
288
|
+
check("引用缺失密钥时失败", dpMiss.code !== 0, JSON.stringify(dpMissJson));
|
|
289
|
+
check("错误信息指明密钥名", /no-such-entry/.test(String(dpMissJson.error)), JSON.stringify(dpMissJson));
|
|
290
|
+
|
|
291
|
+
deleteDpapiSecret("cli-e2e-password");
|
|
292
|
+
|
|
293
|
+
// doctor 报告应把明文标出来
|
|
294
|
+
writeFileSync(
|
|
295
|
+
join(agentDir, "webdav-backup.json"),
|
|
296
|
+
JSON.stringify({ remote: { url: baseUrl, username: "user", password: "plaintext-pw", remoteDir: "bk" } }),
|
|
297
|
+
);
|
|
298
|
+
const doc = await runCli(["doctor", "--json"], env);
|
|
299
|
+
const docJson = lastJson(doc.stdout);
|
|
300
|
+
check("doctor 识别明文密码", docJson.password?.plaintext === true, JSON.stringify(docJson.password));
|
|
301
|
+
|
|
302
|
+
writeFileSync(
|
|
303
|
+
join(agentDir, "webdav-backup.json"),
|
|
304
|
+
JSON.stringify({ remote: { url: baseUrl, username: "user", password: "$PI_TEST_CLI_PW", remoteDir: "bk" } }),
|
|
305
|
+
);
|
|
306
|
+
const doc2 = await runCli(["doctor", "--json"], { ...env, PI_TEST_CLI_PW: "pass" });
|
|
307
|
+
const doc2Json = lastJson(doc2.stdout);
|
|
308
|
+
check("doctor 识别 env 来源", doc2Json.password?.source === "env", JSON.stringify(doc2Json.password));
|
|
309
|
+
check("doctor env 密码不算明文", doc2Json.password?.plaintext === false);
|
|
310
|
+
} else {
|
|
311
|
+
lines.push(" ⏭ 跳过 DPAPI 端到端(非 Windows)");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
dav.server.close();
|
|
315
|
+
try {
|
|
316
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
317
|
+
rmSync(emptyDir, { recursive: true, force: true });
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log(lines.join("\n"));
|
|
323
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
324
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
main().catch((e) => {
|
|
328
|
+
console.log(lines.join("\n"));
|
|
329
|
+
console.error(`\n测试异常:${e?.stack ?? e}`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|