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,684 @@
1
+ /**
2
+ * backup.mjs — 备份引擎
3
+ *
4
+ * 两种模式:
5
+ * 1. 归档模式(默认,稳妥):打包成一个 tar.gz(可加密)整体上传。
6
+ * - 引擎内含极简 tar 写入器,零依赖
7
+ * - 远端文件名:<remoteName>-<时间戳>.tar.gz(或 .tar.gz.pibak)
8
+ * 2. 快照模式(snapshot: true):逐个文件上传到远端 <remoteName>/ 目录,
9
+ * 用 manifest.json 做增量比对,只传变化的文件。
10
+ */
11
+
12
+ import {
13
+ createReadStream,
14
+ createWriteStream,
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ rmSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { tmpdir } from "node:os";
24
+ import { createGzip } from "node:zlib";
25
+ import { pipeline } from "node:stream/promises";
26
+ import { randomBytes } from "node:crypto";
27
+
28
+ import { WebDAVClient } from "./webdav.mjs";
29
+ import { collectFiles } from "./collect.mjs";
30
+ import { encryptFile, sha256Buffer, sha256File } from "./crypto.mjs";
31
+
32
+ const TAR_BLOCK = 512;
33
+
34
+ /** 极简 tar (ustar) 写入器 */
35
+ class TarWriter {
36
+ constructor(out) {
37
+ this.out = out;
38
+ this.pending = 0;
39
+ }
40
+
41
+ #header(name, size, mtimeMs, mode = 0o644, type = "0") {
42
+ const buf = Buffer.alloc(TAR_BLOCK);
43
+ const safeName = Buffer.from(name, "utf8");
44
+ if (safeName.length > 100) {
45
+ // 长名:用 ustar prefix 拆分
46
+ const idx = name.lastIndexOf("/", 155);
47
+ const prefix = Buffer.from(name.slice(0, idx), "utf8");
48
+ const rest = Buffer.from(name.slice(idx + 1), "utf8");
49
+ names: {
50
+ if (prefix.length > 155 || rest.length > 100) break names;
51
+ prefix.copy(buf, 345, 0, Math.min(prefix.length, 155));
52
+ rest.copy(buf, 0, 0, Math.min(rest.length, 100));
53
+ }
54
+ if (buf[0] === 0) {
55
+ safeName.copy(buf, 0, 0, Math.min(safeName.length, 100));
56
+ }
57
+ } else {
58
+ safeName.copy(buf, 0, 0, safeName.length);
59
+ }
60
+
61
+ this.#octal(buf, 100, 8, mode & 0o7777);
62
+ this.#octal(buf, 108, 8, 0);
63
+ this.#octal(buf, 116, 8, 0);
64
+ this.#octal(buf, 124, 12, size);
65
+ this.#octal(buf, 136, 12, Math.floor((mtimeMs ?? Date.now()) / 1000));
66
+ buf.write(" ", 148, 8, "ascii"); // checksum 占位(8 空格)
67
+ buf.write(type, 156, 1, "ascii");
68
+ buf.write("ustar\0", 257, 6, "ascii");
69
+ buf.write("00", 263, 2, "ascii");
70
+
71
+ let sum = 0;
72
+ for (const b of buf) sum += b;
73
+ const cs = sum.toString(8).padStart(6, "0") + "\0 ";
74
+ buf.write(cs, 148, 8, "ascii");
75
+ return buf;
76
+ }
77
+
78
+ #octal(buf, offset, len, value) {
79
+ const s = Math.max(0, Math.floor(value)).toString(8).padStart(len - 1, "0");
80
+ buf.write(s.slice(-(len - 1)), offset, len - 1, "ascii");
81
+ buf[offset + len - 1] = 0;
82
+ }
83
+
84
+ async #write(chunk) {
85
+ if (!this.out.write(chunk)) {
86
+ await new Promise((resolve) => this.out.once("drain", resolve));
87
+ }
88
+ }
89
+
90
+ async addFile(absPath, relPath, { size, mtimeMs }) {
91
+ const header = this.#header(relPath.replace(/\\/g, "/"), size, mtimeMs);
92
+ await this.#write(header);
93
+
94
+ await new Promise((resolve, reject) => {
95
+ const rs = createReadStream(absPath);
96
+ let written = 0;
97
+ rs.on("data", async (c) => {
98
+ written += c.length;
99
+ if (!this.out.write(c)) {
100
+ rs.pause();
101
+ this.out.once("drain", () => rs.resume());
102
+ }
103
+ });
104
+ rs.on("end", () => {
105
+ const pad = (TAR_BLOCK - (written % TAR_BLOCK)) % TAR_BLOCK;
106
+ if (pad > 0) this.out.write(Buffer.alloc(pad));
107
+ resolve();
108
+ });
109
+ rs.on("error", reject);
110
+ });
111
+
112
+ return { written: size };
113
+ }
114
+
115
+ async addBuffer(content, relPath, mtimeMs = Date.now(), mode = 0o600) {
116
+ const buf = Buffer.from(content);
117
+ await this.#write(this.#header(relPath.replace(/\\/g, "/"), buf.length, mtimeMs, mode));
118
+ await this.#write(buf);
119
+ const pad = (TAR_BLOCK - (buf.length % TAR_BLOCK)) % TAR_BLOCK;
120
+ if (pad > 0) await this.#write(Buffer.alloc(pad));
121
+ }
122
+
123
+ /** 结束:两个全零块 */
124
+ async finish() {
125
+ await this.#write(Buffer.alloc(TAR_BLOCK * 2));
126
+ await new Promise((resolve, reject) => {
127
+ this.out.end((err) => (err ? reject(err) : resolve()));
128
+ });
129
+ }
130
+ }
131
+
132
+ function timestamp() {
133
+ const d = new Date();
134
+ const p = (n, w = 2) => String(n).padStart(w, "0");
135
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
136
+ }
137
+
138
+ function escapeRe(s) {
139
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ }
141
+
142
+ /**
143
+ * 归档名的严格匹配式:<remoteName>-YYYYMMDD-HHMMSS.tar.gz[.pibak]
144
+ * 必须精确到时间戳格式,否则 remoteName="pi" 会意外匹配到 "pi-enc-..."、
145
+ * 造成恢复选错文件或 prune 误删其他前缀的备份。
146
+ */
147
+ export function archiveNameRe(remoteName) {
148
+ return new RegExp(`^${escapeRe(remoteName)}-\\d{8}-\\d{6}\\.tar\\.gz(\\.pibak)?$`);
149
+ }
150
+
151
+ /** manifest 文件名:manifest-YYYYMMDD-HHMMSS.json */
152
+ export function manifestNameRe() {
153
+ return /^manifest-\d{8}-\d{6}\.json$/;
154
+ }
155
+
156
+ /** 读取上次备份状态 */
157
+ export function readState(stateFile) {
158
+ try {
159
+ if (!existsSync(stateFile)) return null;
160
+ return JSON.parse(readFileSync(stateFile, "utf8"));
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export function writeState(stateFile, state) {
167
+ try {
168
+ mkdirSync(dirname(stateFile), { recursive: true });
169
+ writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf8");
170
+ } catch {
171
+ /* 状态写入失败不影响备份 */
172
+ }
173
+ }
174
+
175
+ /**
176
+ * 判断一个相对路径的文件是否属于“需要脱敏”的凭据文件。
177
+ *
178
+ * 单一事实来源:脱敏函数、写入路径、审计三方必须用同一个判断,
179
+ * 否则会出现“审计说安全、实际却传了明文”的不一致。
180
+ *
181
+ * 按前缀匹配是有意的:
182
+ * auth.json.bak-2026-... / auth.json.tmp 这类副本同样是明文密钥。
183
+ */
184
+ export function isRedactable(relPath) {
185
+ const base = String(relPath).split("/").pop() ?? "";
186
+ return base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
187
+ }
188
+
189
+ /**
190
+ * 脱敏敏感文件内容。
191
+ *
192
+ * @param {string} relPath
193
+ * @param {Buffer} buf
194
+ * @param {{aggressive?: boolean}} [opts]
195
+ * aggressive=true(未加密备份时使用):按字段名 + 值形状双重判断,
196
+ * 尽量不遗漏任何凭据;aggressive=false 时仍会脱敏已知字段。
197
+ * @returns {Buffer|null} null 表示无需脱敏
198
+ */
199
+ export function redactSensitive(relPath, buf, opts = {}) {
200
+ const aggressive = opts.aggressive !== false;
201
+ const base = relPath.split("/").pop() ?? "";
202
+
203
+ const SECRET_KEY_RE = /key|token|secret|password|passwd|credential|api[-_]?key|auth|cookie|session|bearer|private/i;
204
+ // 常见凭据前缀,以及“长且像随机串”的值
205
+ const SECRET_VALUE_RE =
206
+ /^(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|[A-Za-z0-9_+/=-]{32,})$/;
207
+
208
+ const looksSecret = (v) => typeof v === "string" && SECRET_VALUE_RE.test(v.trim());
209
+
210
+ const redactJson = (obj, { aggressiveValues }) => {
211
+ const walk = (v, keyHint = "") => {
212
+ if (Array.isArray(v)) return v.map((x) => walk(x, keyHint));
213
+ if (v && typeof v === "object") {
214
+ const out = {};
215
+ for (const [k, val] of Object.entries(v)) {
216
+ if (SECRET_KEY_RE.test(k)) {
217
+ // 保留结构,抹掉值
218
+ out[k] = typeof val === "object" && val !== null ? walk(val, k) : "__REDACTED__";
219
+ } else {
220
+ out[k] = walk(val, k);
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+ if (aggressiveValues && looksSecret(v)) return "__REDACTED__";
226
+ return v;
227
+ };
228
+ return walk(obj);
229
+ };
230
+
231
+ // 按前缀匹配(见 isRedactable 注释):
232
+ // auth.json → auth.json / auth.json.bak-* / auth.json.tmp
233
+ // webdav-backup.json → 同上
234
+ // 早期实现只做 basename === "auth.json" 精确匹配,导致 auth.json.bak-*
235
+ // 这类副本绕过脱敏,把明文密钥随备份上传到云端。
236
+ if (base.startsWith("auth.json")) {
237
+ try {
238
+ const obj = JSON.parse(buf.toString("utf8"));
239
+ const SECRET_REF_SCHEMES = ["$", "!", "dpapi:", "file:"];
240
+ const isRef = (v) =>
241
+ typeof v === "string" && SECRET_REF_SCHEMES.some((p) => v.trim().startsWith(p));
242
+
243
+ const walkCredential = (cred) => {
244
+ if (!cred || typeof cred !== "object") return redactJson(cred, { aggressiveValues: aggressive });
245
+ const out = {};
246
+ for (const [k, val] of Object.entries(cred)) {
247
+ if (k === "key") {
248
+ // 保留引用形式(如 $PI_PI_KEY_DEEPSEEK)——它是可恢复的非秘密指针,
249
+ // 抹掉反而会丢失“密钥放在哪”的信息;真正的明文才需要处理
250
+ out[k] = isRef(val) ? val : val === "" ? "" : "__REDACTED__";
251
+ continue;
252
+ }
253
+ out[k] = redactJson(val, { aggressiveValues: aggressive });
254
+ }
255
+ return out;
256
+ };
257
+
258
+ const result = {};
259
+ for (const [provider, cred] of Object.entries(obj)) {
260
+ result[provider] = walkCredential(cred);
261
+ }
262
+ return Buffer.from(JSON.stringify(result, null, 2), "utf8");
263
+ } catch {
264
+ // 解析不了就不能冒险上传原文
265
+ return Buffer.from("__REDACTED_UNPARSEABLE_AUTH_JSON__", "utf8");
266
+ }
267
+ }
268
+
269
+ if (base.startsWith("webdav-backup.json")) {
270
+ try {
271
+ const obj = JSON.parse(buf.toString("utf8"));
272
+ const cleaned = redactJson(obj, { aggressiveValues: false });
273
+ if (obj.encryptKey) cleaned.encryptKey = "__REDACTED__";
274
+ return Buffer.from(JSON.stringify(cleaned, null, 2), "utf8");
275
+ } catch {
276
+ return buf;
277
+ }
278
+ }
279
+
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * 脱敏后的二次保险:扫描【即将上传的实际内容】,确认没有明文凭据漏网。
285
+ *
286
+ * 为什么需要:脱敏是白名单逻辑,一旦出现未预设的文件名
287
+ * (如 auth.json.bak-2026-...),就可能整文件放行。这个审计作为
288
+ * 独立于白名单的第二道防线。
289
+ *
290
+ * 关键:必须审计“脱敏后”的内容,而不是磁盘原文——
291
+ * auth.json / webdav-backup.json 是在写入归档时才脱敏的,
292
+ * 拿磁盘原文去审计会把它们误报为泄露。
293
+ *
294
+ * @returns {{clean: boolean, leaks: {rel: string, reason: string}[]}}
295
+ */
296
+ export function auditForPlaintext(files, opts = {}) {
297
+ const { agentDir, config, readFile = readFileSync } = opts;
298
+ const leaks = [];
299
+
300
+ const SUSPICIOUS = [
301
+ /"key"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([A-Za-z0-9_\-]{16,})"/,
302
+ /"password"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
303
+ /"encryptKey"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
304
+ /sk-[A-Za-z0-9_-]{20,}/,
305
+ /ghp_[A-Za-z0-9]{20,}/,
306
+ /AIza[0-9A-Za-z_-]{30,}/,
307
+ ];
308
+
309
+ for (const f of files) {
310
+ const base = f.rel.split("/").pop() ?? "";
311
+
312
+ // 凭据存储文件根本不该出现在待上传列表里(排除规则应已拦住)
313
+ if (looksCredentialFile(f.rel)) {
314
+ const isAuditable =
315
+ base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
316
+ if (!isAuditable) {
317
+ leaks.push({ rel: f.rel, reason: "凭据类文件未被排除" });
318
+ continue;
319
+ }
320
+ }
321
+
322
+ // 只审计小体量的文本/配置类文件,避免把整个仓库读进内存
323
+ const isKeyFile =
324
+ base.startsWith("auth.json") ||
325
+ base.startsWith("webdav-backup.json") ||
326
+ /(\.json|\.env|\.txt|\.ini|\.conf|\.cfg|\.toml|\.ya?ml|\.ts|\.js|\.mjs)$/i.test(f.rel);
327
+ if (!isKeyFile) continue;
328
+ if (f.size > 2 * 1024 * 1024) continue;
329
+
330
+ let text;
331
+ try {
332
+ text = readFile(join(agentDir, f.rel), "utf8");
333
+ } catch {
334
+ continue;
335
+ }
336
+ if (typeof text !== "string") continue;
337
+
338
+ // 对会被脱敏的文件,审计脱敏后的内容(即真正上传的字节)
339
+ const redacted = redactSensitive(f.rel, Buffer.from(text, "utf8"), {
340
+ aggressive: config ? !config.encrypt : true,
341
+ });
342
+ if (redacted) text = redacted.toString("utf8");
343
+
344
+ for (const re of SUSPICIOUS) {
345
+ const m = re.exec(text);
346
+ if (m) {
347
+ leaks.push({ rel: f.rel, reason: `疑似明文凭据:${m[0].slice(0, 40)}***` });
348
+ break;
349
+ }
350
+ }
351
+ }
352
+
353
+ return { clean: leaks.length === 0, leaks };
354
+ }
355
+
356
+ /**
357
+ * 文件名是否可能包含明文凭据。
358
+ */
359
+ export function looksCredentialFile(relPath) {
360
+ const base = String(relPath).split("/").pop() ?? "";
361
+ const p = String(relPath).replace(/\\/g, "/");
362
+ if (base.startsWith("auth.json")) return true;
363
+ if (base.startsWith("webdav-backup.json")) return true;
364
+ if (p === "secrets.dpapi.json") return true;
365
+ if (p.startsWith("secrets/")) return true;
366
+ return false;
367
+ }
368
+
369
+ /**
370
+ * 执行备份。
371
+ * @param {object} config 由 loadConfig() 得到的 config
372
+ * @param {object} opts
373
+ * @param {(msg: string) => void} [opts.log]
374
+ * @param {string} opts.agentDir
375
+ * @param {string} opts.stateFile
376
+ * @param {boolean} [opts.dryRun]
377
+ * @param {string} [opts.reason] "manual" | "exit"
378
+ */
379
+ export async function runBackup(config, opts) {
380
+ const log = opts.log ?? (() => {});
381
+ const agentDir = opts.agentDir;
382
+ const stateFile = opts.stateFile;
383
+ const snapshot = config.snapshot === true;
384
+ const exclude = [...config.exclude];
385
+ if (!config.includeSessions) exclude.push("sessions/**");
386
+ if (config.encrypt) exclude.push("**/*.pibak");
387
+
388
+ const t0 = Date.now();
389
+ log(`扫描文件… (范围: ${config.scope.join(", ")})`);
390
+ const { files, skipped, totalSize } = collectFiles({ agentDir, scope: config.scope, exclude });
391
+
392
+ if (files.length === 0) {
393
+ return { ok: false, error: "没有需要备份的文件(检查 scope 与 exclude 配置)", files: 0 };
394
+ }
395
+ log(`发现 ${files.length} 个文件,共 ${(totalSize / 1024 / 1024).toFixed(2)} MB(跳过 ${skipped} 项)`);
396
+
397
+ // 未加密上传时,先审计是否还有明文凭据漏网(脱敏白名单之外的第二道防线)
398
+ if (!config.encrypt) {
399
+ const audit = auditForPlaintext(files, { agentDir, config });
400
+ if (!audit.clean) {
401
+ const detail = audit.leaks.map((l) => ` ${l.rel} — ${l.reason}`).join("\n");
402
+ const msg =
403
+ `检测到可能的明文凭据,已中止上传(避免密钥泄露到云端):\n${detail}\n` +
404
+ `处理方式:开启 encrypt(加密备份),或把上述文件加入 exclude。`;
405
+ log(msg.replace(/\n/g, "\n "));
406
+ return { ok: false, error: msg, files: files.length, leaks: audit.leaks };
407
+ }
408
+ }
409
+
410
+ const client = new WebDAVClient({
411
+ baseUrl: config.url,
412
+ username: config.username,
413
+ password: config.password,
414
+ timeoutMs: config.timeoutMs,
415
+ insecureTls: config.insecureTls,
416
+ });
417
+
418
+ const check = await client.check();
419
+ if (!check.ok) {
420
+ return { ok: false, error: `WebDAV 连接失败:${check.error}`, files: 0 };
421
+ }
422
+
423
+ if (opts.dryRun) {
424
+ const audit = auditForPlaintext(files, { agentDir, config });
425
+ return {
426
+ ok: true,
427
+ dryRun: true,
428
+ files: files.length,
429
+ totalSize,
430
+ skipped,
431
+ elapsedMs: Date.now() - t0,
432
+ plaintextAudit: audit.clean ? "clean" : audit.leaks,
433
+ };
434
+ }
435
+
436
+ await client.mkdirp(config.remoteDir);
437
+ const baseDir = config.remoteDir;
438
+
439
+ if (snapshot) {
440
+ return await runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
441
+ }
442
+ return await runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
443
+ }
444
+
445
+ /** 脱敏时的上下文:未加密上传时必须更激进 */
446
+ function redactOpts(config) {
447
+ return { aggressive: !config.encrypt };
448
+ }
449
+
450
+ /** 归档模式:一个 tar.gz(可选加密)整体上传 */
451
+ async function runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
452
+ const tmpName = `pi-backup-${randomBytes(6).toString("hex")}`;
453
+ const tmpDir = join(tmpdir(), tmpName);
454
+ mkdirSync(tmpDir, { recursive: true });
455
+ const plainTar = join(tmpDir, "payload.tar.gz");
456
+ const ts = timestamp();
457
+ const manifest = {
458
+ version: 1,
459
+ createdAt: new Date().toISOString(),
460
+ agentDir,
461
+ scope: config.scope,
462
+ includeSessions: !!config.includeSessions,
463
+ encrypted: !!config.encrypt,
464
+ totalSize,
465
+ fileCount: files.length,
466
+ files: files.map((f) => ({ rel: f.rel, size: f.size, mtimeMs: f.mtimeMs })),
467
+ };
468
+
469
+ try {
470
+ log("打包中…");
471
+ const rawTar = join(tmpDir, "payload.tar");
472
+ const tarOut = createWriteStream(rawTar);
473
+ const tar = new TarWriter(tarOut);
474
+ let redactedCount = 0;
475
+
476
+ for (const f of files) {
477
+ // 配置文件本身脱敏后写入;其余正常写入
478
+ const isSelfConfig = f.rel === "webdav-backup.json" || f.rel.endsWith("/webdav-backup.json");
479
+ if (isRedactable(f.rel)) {
480
+ try {
481
+ const raw = readFileSync(f.abs);
482
+ const redacted = redactSensitive(f.rel, raw, redactOpts(config));
483
+ if (redacted) {
484
+ await tar.addBuffer(redacted, f.rel, f.mtimeMs);
485
+ redactedCount++;
486
+ continue;
487
+ }
488
+ } catch {
489
+ /* 读取失败则按原样 */
490
+ }
491
+ }
492
+ await tar.addFile(f.abs, f.rel, f);
493
+ }
494
+ // 附带 manifest 便于恢复时对照
495
+ await tar.addBuffer(Buffer.from(JSON.stringify(manifest, null, 2), "utf8"), ".pi-backup-manifest.json");
496
+ await tar.finish();
497
+
498
+ log(`压缩中…${redactedCount > 0 ? `(已脱敏 ${redactedCount} 个敏感文件)` : ""}`);
499
+ await pipeline(createReadStream(rawTar), createGzip({ level: 6 }), createWriteStream(plainTar));
500
+
501
+ const plainSize = statSync(plainTar).size;
502
+ let uploadFile = plainTar;
503
+ const baseName = `${config.remoteName}-${ts}.tar.gz`;
504
+ const remoteFileName = config.encrypt ? `${baseName}.pibak` : baseName;
505
+ const plainSha = await sha256File(plainTar);
506
+
507
+ if (config.encrypt) {
508
+ log("加密中…");
509
+ const encFile = join(tmpDir, "payload.tar.gz.pibak");
510
+ await encryptFile(plainTar, encFile, String(config.encryptKey), {
511
+ name: remoteFileName,
512
+ agentDir,
513
+ });
514
+ uploadFile = encFile;
515
+ }
516
+
517
+ const uploadSize = statSync(uploadFile).size;
518
+ const remotePath = `${baseDir}/${remoteFileName}`;
519
+ log(`上传中… ${remoteFileName} (${(uploadSize / 1024 / 1024).toFixed(2)} MB)`);
520
+ await client.put(remotePath, readFileSync(uploadFile), {
521
+ contentType: config.encrypt ? "application/octet-stream" : "application/gzip",
522
+ });
523
+
524
+ // 一并上传 manifest(未加密,便于快速查看历史)
525
+ try {
526
+ await client.put(
527
+ `${baseDir}/manifest-${ts}.json`,
528
+ Buffer.from(JSON.stringify({ ...manifest, archive: remoteFileName, plainSha256: plainSha, archiveSize: uploadSize }, null, 2), "utf8"),
529
+ { contentType: "application/json" },
530
+ );
531
+ } catch {
532
+ /* manifest 上传失败不视为备份失败 */
533
+ }
534
+
535
+ const elapsedMs = Date.now() - t0;
536
+ writeState(stateFile, {
537
+ lastBackupAt: new Date().toISOString(),
538
+ lastReason: "archive",
539
+ remotePath,
540
+ files: files.length,
541
+ redacted: redactedCount,
542
+ });
543
+
544
+ log(`完成:${remotePath} 用时 ${(elapsedMs / 1000).toFixed(1)}s`);
545
+ return {
546
+ ok: true,
547
+ mode: "archive",
548
+ remotePath,
549
+ archive: remoteFileName,
550
+ files: files.length,
551
+ skipped,
552
+ totalSize,
553
+ archiveSize: uploadSize,
554
+ plainSize,
555
+ redacted: redactedCount,
556
+ elapsedMs,
557
+ };
558
+ } finally {
559
+ try {
560
+ rmSync(tmpDir, { recursive: true, force: true });
561
+ } catch {
562
+ /* ignore */
563
+ }
564
+ }
565
+ }
566
+
567
+ /** 快照模式:逐文件上传 + manifest 增量 */
568
+ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
569
+ const dir = `${baseDir}/${config.remoteName}`;
570
+ await client.mkdirp(dir);
571
+ log("读取远端清单…");
572
+
573
+ let prev = {};
574
+ try {
575
+ const raw = await client.get(`${dir}/manifest.json`);
576
+ if (raw) {
577
+ const parsed = JSON.parse(raw.toString("utf8"));
578
+ for (const f of parsed.files ?? []) prev[f.rel] = f;
579
+ }
580
+ } catch {
581
+ /* 无历史清单则全量 */
582
+ }
583
+
584
+ const uploaded = [];
585
+ const skippedSame = [];
586
+ const manifestFiles = [];
587
+
588
+ for (const f of files) {
589
+ const remotePath = `${dir}/${f.rel}`;
590
+ const prevEntry = prev[f.rel];
591
+ const isSelf = f.rel === "webdav-backup.json" || f.rel.endsWith("/webdav-backup.json");
592
+ const isAuth = f.rel === "auth.json" || f.rel.startsWith("auth.json") || f.rel.endsWith("/auth.json");
593
+
594
+ // 增量判断:大小与 mtime 都一致则跳过(auth.json / 配置总是重新上传,因为会脱敏)
595
+ if (prevEntry && prevEntry.size === f.size && prevEntry.mtimeMs === f.mtimeMs && !isAuth && !isSelf) {
596
+ skippedSame.push(f.rel);
597
+ manifestFiles.push(prevEntry);
598
+ continue;
599
+ }
600
+
601
+ let payload = readFileSync(f.abs);
602
+ let stored = f;
603
+ if (isRedactable(f.rel)) {
604
+ const redacted = redactSensitive(f.rel, payload, redactOpts(config));
605
+ if (redacted) {
606
+ payload = redacted;
607
+ stored = { ...f, size: redacted.length };
608
+ }
609
+ }
610
+
611
+ const parts = f.rel.split("/");
612
+ if (parts.length > 1) await client.mkdirp(`${dir}/${parts.slice(0, -1).join("/")}`);
613
+
614
+ log(` 上传 ${f.rel} (${(payload.length / 1024).toFixed(1)} KB)`);
615
+ await client.put(remotePath, payload, { contentType: "application/octet-stream" });
616
+ uploaded.push(f.rel);
617
+ manifestFiles.push({ rel: f.rel, size: stored.size, mtimeMs: f.mtimeMs, sha256: sha256Buffer(payload) });
618
+ }
619
+
620
+ const manifest = {
621
+ version: 1,
622
+ mode: "snapshot",
623
+ createdAt: new Date().toISOString(),
624
+ updatedAt: new Date().toISOString(),
625
+ agentDir,
626
+ scope: config.scope,
627
+ includeSessions: !!config.includeSessions,
628
+ totalSize,
629
+ fileCount: files.length,
630
+ files: manifestFiles,
631
+ };
632
+ await client.put(`${dir}/manifest.json`, Buffer.from(JSON.stringify(manifest, null, 2), "utf8"), {
633
+ contentType: "application/json",
634
+ });
635
+
636
+ const elapsedMs = Date.now() - t0;
637
+ writeState(stateFile, {
638
+ lastBackupAt: new Date().toISOString(),
639
+ lastReason: "snapshot",
640
+ remoteDir: dir,
641
+ files: files.length,
642
+ uploaded: uploaded.length,
643
+ });
644
+
645
+ log(`完成:上传 ${uploaded.length} 个,跳过 ${skippedSame.length} 个未变化文件,用时 ${(elapsedMs / 1000).toFixed(1)}s`);
646
+ return {
647
+ ok: true,
648
+ mode: "snapshot",
649
+ remoteDir: dir,
650
+ files: files.length,
651
+ uploaded: uploaded.length,
652
+ skippedSame: skippedSame.length,
653
+ skipped,
654
+ totalSize,
655
+ elapsedMs,
656
+ };
657
+ }
658
+
659
+ /** 清理旧版本,只保留最近 N 个归档(仅限当前 remoteName 前缀,不动其他前缀的备份) */
660
+ export async function pruneOldArchives(config, keep, log = () => {}) {
661
+ if (!keep || keep <= 0) return { ok: true, deleted: [] };
662
+ const client = new WebDAVClient({
663
+ baseUrl: config.url,
664
+ username: config.username,
665
+ password: config.password,
666
+ timeoutMs: config.timeoutMs,
667
+ insecureTls: config.insecureTls,
668
+ });
669
+ await client.mkdirp(config.remoteDir);
670
+ const entries = await client.list(config.remoteDir);
671
+ const isArchive = archiveNameRe(config.remoteName);
672
+ const archives = entries
673
+ .filter((e) => !e.isCollection && isArchive.test(e.name))
674
+ .sort((a, b) => (a.name < b.name ? 1 : -1)); // 名称含时间戳,倒序即从新到旧
675
+
676
+ const toDelete = archives.slice(keep);
677
+ const deleted = [];
678
+ for (const a of toDelete) {
679
+ await client.del(`${config.remoteDir}/${a.name}`);
680
+ deleted.push(a.name);
681
+ log(` 已删除旧备份 ${a.name}`);
682
+ }
683
+ return { ok: true, deleted, kept: archives.length - deleted.length };
684
+ }