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
|
@@ -26,7 +26,8 @@ import { pipeline } from "node:stream/promises";
|
|
|
26
26
|
import { randomBytes } from "node:crypto";
|
|
27
27
|
|
|
28
28
|
import { WebDAVClient } from "./webdav.mjs";
|
|
29
|
-
import { collectFiles } from "./collect.mjs";
|
|
29
|
+
import { collectFiles, deriveSensitivePaths } from "./collect.mjs";
|
|
30
|
+
import { buildExtraMap, EXTRA_MANIFEST_NAME } from "./extras.mjs";
|
|
30
31
|
import { encryptFile, sha256Buffer, sha256File } from "./crypto.mjs";
|
|
31
32
|
|
|
32
33
|
const TAR_BLOCK = 512;
|
|
@@ -182,113 +183,151 @@ export function writeState(stateFile, state) {
|
|
|
182
183
|
* auth.json.bak-2026-... / auth.json.tmp 这类副本同样是明文密钥。
|
|
183
184
|
*/
|
|
184
185
|
export function isRedactable(relPath) {
|
|
186
|
+
const p = String(relPath).replace(/\\/g, "/");
|
|
187
|
+
// 插件实现代码是第三方依赖,不参与脱敏(避免误改;其中个别的 auth.json 多为测试固件)
|
|
188
|
+
if (p.startsWith("npm/node_modules/") || p.includes("/node_modules/")) return false;
|
|
185
189
|
const base = String(relPath).split("/").pop() ?? "";
|
|
186
190
|
return base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
for (const [k, val] of Object.entries(v)) {
|
|
220
|
-
if (SECRET_KEY_RE.test(k)) {
|
|
221
|
-
// 保留结构,抹掉值。
|
|
222
|
-
// 只替换字符串:布尔/数字不可能是凭据,
|
|
223
|
-
// 把 false 替换成字符串会破坏恢复后的配置语义(回归:includeSessions 曾被误伤)
|
|
224
|
-
out[k] =
|
|
225
|
-
typeof val === "object" && val !== null
|
|
226
|
-
? walk(val, k)
|
|
227
|
-
: typeof val === "string"
|
|
228
|
-
? "__REDACTED__"
|
|
229
|
-
: val;
|
|
230
|
-
} else {
|
|
231
|
-
out[k] = walk(val, k);
|
|
232
|
-
}
|
|
193
|
+
const SECRET_KEY_RE = /key|token|secret|password|passwd|credential|api[-_]?key|auth(?!or)|cookie|session[-_]?(token|key|secret|id)|bearer|private/i;
|
|
194
|
+
// 密钥引用($ENV / !cmd / dpapi: / file:)是“密钥放在哪”的指针,不是秘密本身,应保留
|
|
195
|
+
const SECRET_REF_SCHEMES = ["$", "!", "dpapi:", "file:"];
|
|
196
|
+
// 常见凭据前缀,以及“长且像随机串”的值
|
|
197
|
+
const SECRET_VALUE_RE =
|
|
198
|
+
/^(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,})$/;
|
|
199
|
+
|
|
200
|
+
const isSecretRef = (v) =>
|
|
201
|
+
typeof v === "string" && SECRET_REF_SCHEMES.some((p) => v.trim().startsWith(p));
|
|
202
|
+
const looksSecret = (v) => typeof v === "string" && SECRET_VALUE_RE.test(v.trim());
|
|
203
|
+
|
|
204
|
+
/** 按字段名 + (可选)值形状递归脱敏一个 JSON 值 */
|
|
205
|
+
function redactJsonValue(obj, { aggressiveValues }) {
|
|
206
|
+
const walk = (v, keyHint = "") => {
|
|
207
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, keyHint));
|
|
208
|
+
if (v && typeof v === "object") {
|
|
209
|
+
const out = {};
|
|
210
|
+
for (const [k, val] of Object.entries(v)) {
|
|
211
|
+
if (SECRET_KEY_RE.test(k)) {
|
|
212
|
+
// 保留结构,抹掉值。
|
|
213
|
+
// 只替换字符串:布尔/数字不可能是凭据,
|
|
214
|
+
// 把 false 替换成字符串会破坏恢复后的配置语义(回归:includeSessions 曾被误伤)
|
|
215
|
+
out[k] =
|
|
216
|
+
typeof val === "object" && val !== null
|
|
217
|
+
? walk(val, k)
|
|
218
|
+
: typeof val === "string"
|
|
219
|
+
? "__REDACTED__"
|
|
220
|
+
: val;
|
|
221
|
+
} else {
|
|
222
|
+
out[k] = walk(val, k);
|
|
233
223
|
}
|
|
234
|
-
return out;
|
|
235
224
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
if (aggressiveValues && looksSecret(v)) return "__REDACTED__";
|
|
228
|
+
return v;
|
|
240
229
|
};
|
|
230
|
+
return walk(obj);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 对一段文本内容做脱敏(含凭据文件与 agentDir 外的 JSON/配置)。
|
|
235
|
+
* @returns {string} 脱敏后的文本
|
|
236
|
+
*/
|
|
237
|
+
export function redactSensitiveContent(relPath, text, opts = {}) {
|
|
238
|
+
const aggressive = opts.aggressive !== false;
|
|
239
|
+
const base = String(relPath).split("/").pop() ?? "";
|
|
241
240
|
|
|
242
|
-
// 按前缀匹配(见 isRedactable 注释):
|
|
243
|
-
// auth.json → auth.json / auth.json.bak-* / auth.json.tmp
|
|
244
|
-
// webdav-backup.json → 同上
|
|
245
|
-
// 早期实现只做 basename === "auth.json" 精确匹配,导致 auth.json.bak-*
|
|
246
|
-
// 这类副本绕过脱敏,把明文密钥随备份上传到云端。
|
|
247
241
|
if (base.startsWith("auth.json")) {
|
|
248
242
|
try {
|
|
249
|
-
const obj = JSON.parse(
|
|
243
|
+
const obj = JSON.parse(text);
|
|
250
244
|
const walkCredential = (cred) => {
|
|
251
|
-
if (!cred || typeof cred !== "object") return
|
|
245
|
+
if (!cred || typeof cred !== "object") return redactJsonValue(cred, { aggressiveValues: aggressive });
|
|
252
246
|
const out = {};
|
|
253
247
|
for (const [k, val] of Object.entries(cred)) {
|
|
254
248
|
if (k === "key") {
|
|
255
249
|
// 保留引用形式(如 $PI_PI_KEY_DEEPSEEK)——它是可恢复的非秘密指针,
|
|
256
250
|
// 抹掉反而会丢失“密钥放在哪”的信息;真正的明文才需要处理
|
|
257
|
-
out[k] =
|
|
251
|
+
out[k] = isSecretRef(val) ? val : val === "" ? "" : "__REDACTED__";
|
|
258
252
|
continue;
|
|
259
253
|
}
|
|
260
|
-
out[k] =
|
|
254
|
+
out[k] = redactJsonValue(val, { aggressiveValues: aggressive });
|
|
261
255
|
}
|
|
262
256
|
return out;
|
|
263
257
|
};
|
|
264
|
-
|
|
265
258
|
const result = {};
|
|
266
259
|
for (const [provider, cred] of Object.entries(obj)) {
|
|
267
260
|
result[provider] = walkCredential(cred);
|
|
268
261
|
}
|
|
269
|
-
return
|
|
262
|
+
return JSON.stringify(result, null, 2);
|
|
270
263
|
} catch {
|
|
271
|
-
|
|
272
|
-
return Buffer.from("__REDACTED_UNPARSEABLE_AUTH_JSON__", "utf8");
|
|
264
|
+
return "__REDACTED_UNPARSEABLE_AUTH_JSON__";
|
|
273
265
|
}
|
|
274
266
|
}
|
|
275
267
|
|
|
276
268
|
if (base.startsWith("webdav-backup.json")) {
|
|
277
269
|
try {
|
|
278
|
-
const obj = JSON.parse(
|
|
279
|
-
const cleaned =
|
|
270
|
+
const obj = JSON.parse(text);
|
|
271
|
+
const cleaned = redactJsonValue(obj, { aggressiveValues: false });
|
|
280
272
|
// 与 auth.json 同一约定:密钥引用保留(指针非秘密),
|
|
281
273
|
// 明文由字段名规则抹掉,这里只需把被误抹的引用还原
|
|
282
|
-
if (typeof obj.encryptKey === "string" &&
|
|
274
|
+
if (typeof obj.encryptKey === "string" && isSecretRef(obj.encryptKey)) cleaned.encryptKey = obj.encryptKey;
|
|
283
275
|
const pw = obj.remote && typeof obj.remote === "object" ? obj.remote.password : undefined;
|
|
284
|
-
if (typeof pw === "string" &&
|
|
285
|
-
return
|
|
276
|
+
if (typeof pw === "string" && isSecretRef(pw) && cleaned.remote) cleaned.remote.password = pw;
|
|
277
|
+
return JSON.stringify(cleaned, null, 2);
|
|
286
278
|
} catch {
|
|
287
|
-
return
|
|
279
|
+
return text;
|
|
288
280
|
}
|
|
289
281
|
}
|
|
290
282
|
|
|
291
|
-
|
|
283
|
+
// agentDir 之外的 JSON/配置(如 ~/.pi/settings.json、~/.config/rpiv-*/config.json):
|
|
284
|
+
// 用同一套字段名规则脱敏,不启用“值形状”激进模式(避免把模型名等误伤)。
|
|
285
|
+
// 仅对 extra/ 路径生效,避免改写 agentDir 内的普通配置格式。
|
|
286
|
+
const relPosix = String(relPath).replace(/\\/g, "/");
|
|
287
|
+
if (relPosix.startsWith("extra/") && (/\.json$/i.test(base) || /\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(base) || base === ".env" || base.startsWith(".env."))) {
|
|
288
|
+
if (/\.json$/i.test(base)) {
|
|
289
|
+
try {
|
|
290
|
+
return JSON.stringify(redactJsonValue(JSON.parse(text), { aggressiveValues: false }), null, 2);
|
|
291
|
+
} catch {
|
|
292
|
+
return text;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 非 JSON 的键值文本:逐行做 `key=value` / `key: value` 脱敏
|
|
296
|
+
return text
|
|
297
|
+
.split(/\r?\n/)
|
|
298
|
+
.map((line) => {
|
|
299
|
+
const m = /^(\s*["']?[A-Za-z0-9_.-]*(?:key|token|secret|password|passwd|credential|auth)[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(.*)$/i.exec(line);
|
|
300
|
+
return m && m[2].trim() && !isSecretRef(m[2].trim()) ? `${m[1]}__REDACTED__` : line;
|
|
301
|
+
})
|
|
302
|
+
.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return text;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 脱敏敏感文件内容。
|
|
310
|
+
*
|
|
311
|
+
* @param {string} relPath
|
|
312
|
+
* @param {Buffer} buf
|
|
313
|
+
* @param {{aggressive?: boolean}} [opts]
|
|
314
|
+
* aggressive=true(未加密备份时使用):按字段名 + 值形状双重判断,
|
|
315
|
+
* 尽量不遗漏任何凭据;aggressive=false 时仍会脱敏已知字段。
|
|
316
|
+
* @returns {Buffer|null} null 表示无需脱敏
|
|
317
|
+
*/
|
|
318
|
+
export function redactSensitive(relPath, buf, opts = {}) {
|
|
319
|
+
const base = String(relPath).split("/").pop() ?? "";
|
|
320
|
+
const relPosix = String(relPath).replace(/\\/g, "/");
|
|
321
|
+
const isCred = base.startsWith("auth.json") || base.startsWith("webdav-backup.json");
|
|
322
|
+
const isExtraData =
|
|
323
|
+
relPosix.startsWith("extra/") &&
|
|
324
|
+
(/\.json$/i.test(base) ||
|
|
325
|
+
/\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(base) ||
|
|
326
|
+
base === ".env" ||
|
|
327
|
+
base.startsWith(".env."));
|
|
328
|
+
if (!isCred && !isExtraData) return null;
|
|
329
|
+
const out = redactSensitiveContent(relPath, buf.toString("utf8"), opts);
|
|
330
|
+
return Buffer.from(out, "utf8");
|
|
292
331
|
}
|
|
293
332
|
|
|
294
333
|
/**
|
|
@@ -305,13 +344,14 @@ export function redactSensitive(relPath, buf, opts = {}) {
|
|
|
305
344
|
* @returns {{clean: boolean, leaks: {rel: string, reason: string}[]}}
|
|
306
345
|
*/
|
|
307
346
|
export function auditForPlaintext(files, opts = {}) {
|
|
308
|
-
const { agentDir, config, readFile = readFileSync } = opts;
|
|
347
|
+
const { agentDir, config, readFile = readFileSync, extraSensitive = [] } = opts;
|
|
309
348
|
const leaks = [];
|
|
310
349
|
|
|
311
350
|
const SUSPICIOUS = [
|
|
312
351
|
/"key"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([A-Za-z0-9_\-]{16,})"/,
|
|
313
352
|
/"password"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
|
|
314
353
|
/"encryptKey"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([^"\s]{6,})"/,
|
|
354
|
+
/"apiKey"\s*:\s*"(?!\$|!|dpapi:|file:|__REDACTED)([A-Za-z0-9_\-]{16,})"/,
|
|
315
355
|
/sk-[A-Za-z0-9_-]{20,}/,
|
|
316
356
|
/ghp_[A-Za-z0-9]{20,}/,
|
|
317
357
|
/AIza[0-9A-Za-z_-]{30,}/,
|
|
@@ -320,6 +360,12 @@ export function auditForPlaintext(files, opts = {}) {
|
|
|
320
360
|
for (const f of files) {
|
|
321
361
|
const base = f.rel.split("/").pop() ?? "";
|
|
322
362
|
|
|
363
|
+
// 插件实现代码(node_modules)是第三方依赖,不是用户配置:
|
|
364
|
+
// 里面可能有大量字符串字面量(示例密钥、测试数据),逐文件审计既慢又容易误报。
|
|
365
|
+
// 备份它只是为了恢复后能直接用,不参与凭据审计。
|
|
366
|
+
const relPosix0 = String(f.rel).replace(/\\/g, "/");
|
|
367
|
+
if (relPosix0.startsWith("npm/node_modules/") || relPosix0.includes("/node_modules/")) continue;
|
|
368
|
+
|
|
323
369
|
// 凭据存储文件根本不该出现在待上传列表里(排除规则应已拦住)
|
|
324
370
|
if (looksCredentialFile(f.rel)) {
|
|
325
371
|
const isAuditable =
|
|
@@ -340,17 +386,24 @@ export function auditForPlaintext(files, opts = {}) {
|
|
|
340
386
|
|
|
341
387
|
let text;
|
|
342
388
|
try {
|
|
343
|
-
|
|
389
|
+
// extraScope 的文件在 agentDir 之外,必须用绝对路径读(否则 join 会读错位置)
|
|
390
|
+
const abs = String(f.rel).replace(/\\/g, "/").startsWith("extra/") ? f.abs : join(agentDir, f.rel);
|
|
391
|
+
text = readFile(abs, "utf8");
|
|
344
392
|
} catch {
|
|
345
393
|
continue;
|
|
346
394
|
}
|
|
347
395
|
if (typeof text !== "string") continue;
|
|
348
396
|
|
|
349
397
|
// 对会被脱敏的文件,审计脱敏后的内容(即真正上传的字节)
|
|
350
|
-
const
|
|
398
|
+
const redactOpts2 = {
|
|
351
399
|
aggressive: config ? !config.encrypt : true,
|
|
352
|
-
}
|
|
400
|
+
};
|
|
401
|
+
const redacted = redactSensitive(f.rel, Buffer.from(text, "utf8"), redactOpts2);
|
|
353
402
|
if (redacted) text = redacted.toString("utf8");
|
|
403
|
+
// 已经脱敏过一次的 extra 数据,再做值形状命中(如 ~/.pi/settings.json 里的模型 apiKey)
|
|
404
|
+
if (String(f.rel).replace(/\\/g, "/").startsWith("extra/") && isExtraSensitive(f.rel, extraSensitive)) {
|
|
405
|
+
text = redactSensitiveContent(f.rel, text, redactOpts2);
|
|
406
|
+
}
|
|
354
407
|
|
|
355
408
|
for (const re of SUSPICIOUS) {
|
|
356
409
|
const m = re.exec(text);
|
|
@@ -398,7 +451,10 @@ export async function runBackup(config, opts) {
|
|
|
398
451
|
|
|
399
452
|
const t0 = Date.now();
|
|
400
453
|
log(`扫描文件… (范围: ${config.scope.join(", ")})`);
|
|
401
|
-
const
|
|
454
|
+
const extraScope = Array.isArray(config.extraScope) ? config.extraScope : [];
|
|
455
|
+
const { files, skipped, totalSize } = collectFiles({ agentDir, scope: config.scope, exclude, extraScope });
|
|
456
|
+
// agentDir 之外的路径也可能含凭据(~/.pi/settings.json 等),推导出需脱敏的归档内路径
|
|
457
|
+
const extraSensitive = deriveSensitivePaths(extraScope);
|
|
402
458
|
|
|
403
459
|
if (files.length === 0) {
|
|
404
460
|
return { ok: false, error: "没有需要备份的文件(检查 scope 与 exclude 配置)", files: 0 };
|
|
@@ -407,7 +463,7 @@ export async function runBackup(config, opts) {
|
|
|
407
463
|
|
|
408
464
|
// 未加密上传时,先审计是否还有明文凭据漏网(脱敏白名单之外的第二道防线)
|
|
409
465
|
if (!config.encrypt) {
|
|
410
|
-
const audit = auditForPlaintext(files, { agentDir, config });
|
|
466
|
+
const audit = auditForPlaintext(files, { agentDir, config, extraSensitive });
|
|
411
467
|
if (!audit.clean) {
|
|
412
468
|
const detail = audit.leaks.map((l) => ` ${l.rel} — ${l.reason}`).join("\n");
|
|
413
469
|
const msg =
|
|
@@ -432,7 +488,7 @@ export async function runBackup(config, opts) {
|
|
|
432
488
|
}
|
|
433
489
|
|
|
434
490
|
if (opts.dryRun) {
|
|
435
|
-
const audit = auditForPlaintext(files, { agentDir, config });
|
|
491
|
+
const audit = auditForPlaintext(files, { agentDir, config, extraSensitive });
|
|
436
492
|
return {
|
|
437
493
|
ok: true,
|
|
438
494
|
dryRun: true,
|
|
@@ -448,9 +504,9 @@ export async function runBackup(config, opts) {
|
|
|
448
504
|
const baseDir = config.remoteDir;
|
|
449
505
|
|
|
450
506
|
if (snapshot) {
|
|
451
|
-
return await runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
|
|
507
|
+
return await runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope, extraSensitive });
|
|
452
508
|
}
|
|
453
|
-
return await runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile });
|
|
509
|
+
return await runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope, extraSensitive });
|
|
454
510
|
}
|
|
455
511
|
|
|
456
512
|
/** 脱敏时的上下文:未加密上传时必须更激进 */
|
|
@@ -458,8 +514,15 @@ function redactOpts(config) {
|
|
|
458
514
|
return { aggressive: !config.encrypt };
|
|
459
515
|
}
|
|
460
516
|
|
|
517
|
+
/** 归档内相对路径是否命中 extraScope 推导出的敏感路径(目录前缀或精确文件) */
|
|
518
|
+
function isExtraSensitive(rel, extraSensitive) {
|
|
519
|
+
if (!extraSensitive || extraSensitive.length === 0) return false;
|
|
520
|
+
const r = String(rel).replace(/\\/g, "/");
|
|
521
|
+
return extraSensitive.some((p) => (p.endsWith("/") ? r.startsWith(p) : r === p));
|
|
522
|
+
}
|
|
523
|
+
|
|
461
524
|
/** 归档模式:一个 tar.gz(可选加密)整体上传 */
|
|
462
|
-
async function runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
|
|
525
|
+
async function runArchive({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope = [], extraSensitive = [] }) {
|
|
463
526
|
const tmpName = `pi-backup-${randomBytes(6).toString("hex")}`;
|
|
464
527
|
const tmpDir = join(tmpdir(), tmpName);
|
|
465
528
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -470,7 +533,9 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
|
|
|
470
533
|
createdAt: new Date().toISOString(),
|
|
471
534
|
agentDir,
|
|
472
535
|
scope: config.scope,
|
|
536
|
+
extraScope,
|
|
473
537
|
includeSessions: !!config.includeSessions,
|
|
538
|
+
includePlugins: !!config.includePlugins,
|
|
474
539
|
encrypted: !!config.encrypt,
|
|
475
540
|
totalSize,
|
|
476
541
|
fileCount: files.length,
|
|
@@ -485,9 +550,8 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
|
|
|
485
550
|
let redactedCount = 0;
|
|
486
551
|
|
|
487
552
|
for (const f of files) {
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
if (isRedactable(f.rel)) {
|
|
553
|
+
// 配置文件本身与 extraScope 下的数据类文件脱敏后写入;其余正常写入
|
|
554
|
+
if (isRedactable(f.rel) || isExtraSensitive(f.rel, extraSensitive)) {
|
|
491
555
|
try {
|
|
492
556
|
const raw = readFileSync(f.abs);
|
|
493
557
|
const redacted = redactSensitive(f.rel, raw, redactOpts(config));
|
|
@@ -504,6 +568,11 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
|
|
|
504
568
|
}
|
|
505
569
|
// 附带 manifest 便于恢复时对照
|
|
506
570
|
await tar.addBuffer(Buffer.from(JSON.stringify(manifest, null, 2), "utf8"), ".pi-backup-manifest.json");
|
|
571
|
+
// 额外数据(extra/)的可移植映射:记录源锚点,恢复时可在不同用户名/盘符的机器上重算目标路径
|
|
572
|
+
const extraMap = buildExtraMap(files);
|
|
573
|
+
if (extraMap.entries.length > 0) {
|
|
574
|
+
await tar.addBuffer(Buffer.from(JSON.stringify(extraMap, null, 2), "utf8"), EXTRA_MANIFEST_NAME);
|
|
575
|
+
}
|
|
507
576
|
await tar.finish();
|
|
508
577
|
|
|
509
578
|
log(`压缩中…${redactedCount > 0 ? `(已脱敏 ${redactedCount} 个敏感文件)` : ""}`);
|
|
@@ -576,7 +645,7 @@ async function runArchive({ client, config, files, totalSize, skipped, baseDir,
|
|
|
576
645
|
}
|
|
577
646
|
|
|
578
647
|
/** 快照模式:逐文件上传 + manifest 增量 */
|
|
579
|
-
async function runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile }) {
|
|
648
|
+
async function runSnapshot({ client, config, files, totalSize, skipped, baseDir, agentDir, log, t0, stateFile, extraScope = [], extraSensitive = [] }) {
|
|
580
649
|
const dir = `${baseDir}/${config.remoteName}`;
|
|
581
650
|
await client.mkdirp(dir);
|
|
582
651
|
log("读取远端清单…");
|
|
@@ -601,9 +670,10 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
|
|
|
601
670
|
const prevEntry = prev[f.rel];
|
|
602
671
|
const isSelf = f.rel === "webdav-backup.json" || f.rel.endsWith("/webdav-backup.json");
|
|
603
672
|
const isAuth = f.rel === "auth.json" || f.rel.startsWith("auth.json") || f.rel.endsWith("/auth.json");
|
|
673
|
+
const isExtraData = isExtraSensitive(f.rel, extraSensitive);
|
|
604
674
|
|
|
605
|
-
// 增量判断:大小与 mtime 都一致则跳过(auth.json /
|
|
606
|
-
if (prevEntry && prevEntry.size === f.size && prevEntry.mtimeMs === f.mtimeMs && !isAuth && !isSelf) {
|
|
675
|
+
// 增量判断:大小与 mtime 都一致则跳过(auth.json / 配置 / extra 数据总是重新上传,因为会脱敏)
|
|
676
|
+
if (prevEntry && prevEntry.size === f.size && prevEntry.mtimeMs === f.mtimeMs && !isAuth && !isSelf && !isExtraData) {
|
|
607
677
|
skippedSame.push(f.rel);
|
|
608
678
|
manifestFiles.push(prevEntry);
|
|
609
679
|
continue;
|
|
@@ -611,7 +681,7 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
|
|
|
611
681
|
|
|
612
682
|
let payload = readFileSync(f.abs);
|
|
613
683
|
let stored = f;
|
|
614
|
-
if (isRedactable(f.rel)) {
|
|
684
|
+
if (isRedactable(f.rel) || isExtraData) {
|
|
615
685
|
const redacted = redactSensitive(f.rel, payload, redactOpts(config));
|
|
616
686
|
if (redacted) {
|
|
617
687
|
payload = redacted;
|
|
@@ -635,7 +705,9 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
|
|
|
635
705
|
updatedAt: new Date().toISOString(),
|
|
636
706
|
agentDir,
|
|
637
707
|
scope: config.scope,
|
|
708
|
+
extraScope,
|
|
638
709
|
includeSessions: !!config.includeSessions,
|
|
710
|
+
includePlugins: !!config.includePlugins,
|
|
639
711
|
totalSize,
|
|
640
712
|
fileCount: files.length,
|
|
641
713
|
files: manifestFiles,
|
|
@@ -644,6 +716,14 @@ async function runSnapshot({ client, config, files, totalSize, skipped, baseDir,
|
|
|
644
716
|
contentType: "application/json",
|
|
645
717
|
});
|
|
646
718
|
|
|
719
|
+
// 额外数据(extra/)的可移植映射
|
|
720
|
+
const extraMap = buildExtraMap(files);
|
|
721
|
+
if (extraMap.entries.length > 0) {
|
|
722
|
+
await client.put(`${dir}/${EXTRA_MANIFEST_NAME}`, Buffer.from(JSON.stringify(extraMap, null, 2), "utf8"), {
|
|
723
|
+
contentType: "application/json",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
647
727
|
const elapsedMs = Date.now() - t0;
|
|
648
728
|
writeState(stateFile, {
|
|
649
729
|
lastBackupAt: new Date().toISOString(),
|