nx-sk 0.6.1 → 0.7.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/CHANGELOG.md +17 -0
- package/README.md +15 -8
- package/assets/nx-sk/references/30-export-backup.md +12 -4
- package/package.json +1 -1
- package/src/modules/backup/index.js +9 -4
- package/src/modules/backup/service.js +494 -49
- package/src/modules/backup/view.jsx +133 -22
- package/src/web/public/assets/{files-BNh48qeo.js → files-DcDiKjwc.js} +1 -1
- package/src/web/public/assets/{index-B7CtqgoG.js → index-BygHBMGo.js} +2 -2
- package/src/web/public/assets/{view-BAYyPWdU.js → view-B1Vx3EvJ.js} +1 -1
- package/src/web/public/assets/{view-1kwWQYbz.js → view-COgrD5Um.js} +1 -1
- package/src/web/public/assets/{view-Bu70Q6GO.js → view-Cct73k6i.js} +1 -1
- package/src/web/public/assets/{view-D5LfHr7W.js → view-CnZk33Lp.js} +1 -1
- package/src/web/public/assets/view-CoUwzqKQ.js +5 -0
- package/src/web/public/assets/{view-BIdVqIGe.js → view-h-QmH_sE.js} +1 -1
- package/src/web/public/index.html +1 -1
- package/src/web/public/assets/view-DXqI-4_F.js +0 -3
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 导出:用户设置口令 → 先把 secret 栏目的密文字段用**本机 vaultKey 解开**(明文),
|
|
4
4
|
// 再把整份 payload 用口令派生 key 加密落到备份文件;口令不落盘。
|
|
5
|
-
//
|
|
5
|
+
// v3 起 payload **恒含**附件台账(files)——它随 store.json 走,缺了它任何一次
|
|
6
|
+
// 导入都会把台账清零(真实事故);`--with-files` 再把台账指向的原件读成
|
|
7
|
+
// {rel → {b64, sha256}} 单独加密嵌入(archive.filesData,同一把口令派生 key)。
|
|
8
|
+
// 导入(缺省 = 合并):解密后按「只增不减」并入当前数据——补缺失的栏目/条目/台账行、
|
|
9
|
+
// 补空字段、把备份多出的分组与字段只增补进已有栏目(core/fields.js 的 mergeTemplate);
|
|
10
|
+
// 两边都有值且不同 → **保留本地**、列入冲突清单交给用户处理;本地已有数据与结构永不删改。
|
|
11
|
+
// 敏感字段在进 values 前用**本机 vaultKey** 重新加密;报告里的值一律打码摘要,
|
|
12
|
+
// 密钥明文绝不进报告/日志。
|
|
13
|
+
// 导入(--replace):整份替换(旧行为,灾难恢复/跨机器迁移用),同样带台账与原件,先留安全快照。
|
|
6
14
|
//
|
|
7
15
|
// 这样跨机器恢复:B 机拿到口令后 secret 字段就是明文,**不再依赖 A 机的 vaultKey**。
|
|
8
16
|
// 这是 store.json 内的密文被解开的瞬态——落回磁盘后字段仍是 vaultKey 加密形态(每次
|
|
@@ -10,21 +18,27 @@
|
|
|
10
18
|
//
|
|
11
19
|
// 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue)。
|
|
12
20
|
import fsp from 'node:fs/promises';
|
|
13
|
-
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
22
|
import { resolve } from 'node:path';
|
|
15
23
|
import { displayPath, snapshotDir } from '../../core/paths.js';
|
|
16
|
-
import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
|
|
17
|
-
import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
|
|
18
|
-
import { decryptValue, deriveKey, encryptValue, isCipherBlob } from '../../core/crypto.js';
|
|
24
|
+
import { ensureDir, fileSize, hashFile, pathExists, writeAtomic } from '../../core/fsx.js';
|
|
25
|
+
import { loadStore, mutateStore, normalize, saveStore, snapshotStore } from '../../core/store.js';
|
|
26
|
+
import { decryptValue, deriveKey, encryptValue, isCipherBlob, maskValue } from '../../core/crypto.js';
|
|
19
27
|
import { badInput, blocked } from '../../core/errors.js';
|
|
20
|
-
import { nowIso, stamp } from '../../core/ids.js';
|
|
21
|
-
import { isSensitiveField } from '../../core/fields.js';
|
|
28
|
+
import { newId, nowIso, stamp } from '../../core/ids.js';
|
|
29
|
+
import { isSensitiveField, mergeTemplate } from '../../core/fields.js';
|
|
22
30
|
import { resolveVaultKey } from '../../core/vault.js';
|
|
31
|
+
import { fileRecordAbs, normalizeFileRecord } from '../../core/attachments.js';
|
|
32
|
+
import { formatValue, isBlank } from '../../core/render.js';
|
|
23
33
|
|
|
24
34
|
export const BACKUP_KIND = 'encrypted-backup';
|
|
25
|
-
export const BACKUP_VERSION =
|
|
26
|
-
|
|
35
|
+
export const BACKUP_VERSION = 3;
|
|
36
|
+
// v2(0.5.1–0.6.2)没有附件二进制,其余照读;v3 恒含台账、可选嵌入原件
|
|
37
|
+
const SUPPORTED_VERSIONS = new Set([2, 3]);
|
|
38
|
+
const ENV_BACKUP_PASSWORD = 'NX_SK_BACKUP_PASSWORD';
|
|
27
39
|
const EXT = '.nxskbak';
|
|
40
|
+
// 报告明细封顶:超过只给计数 +「…共 N 处」尾行,报告不是数据浏览器
|
|
41
|
+
const REPORT_CAP = 20;
|
|
28
42
|
|
|
29
43
|
function passwordFrom(ctx) {
|
|
30
44
|
const pw = ctx?.password ?? process.env[ENV_BACKUP_PASSWORD];
|
|
@@ -39,8 +53,8 @@ function passwordFrom(ctx) {
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
56
|
+
* 导出时:用本机 vaultKey **解开** secret 栏目的密文字段。
|
|
57
|
+
* 导出文件不再依赖 A 机的 ~/.vaultkey / NX_SK_PASSPHRASE。
|
|
44
58
|
*/
|
|
45
59
|
async function unsealSecretsForExport(payload) {
|
|
46
60
|
const sections = payload.sections || [];
|
|
@@ -66,15 +80,67 @@ async function unsealSecretsForExport(payload) {
|
|
|
66
80
|
return { ...payload, entries: newEntries, _unsealed: unsealed };
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/**
|
|
84
|
+
* 导入时:payload 里的 secret 字段已是明文,**用本机 vaultKey 重新加密**后落盘。
|
|
85
|
+
* 这样 store.json 里仍是密文(落盘防护不丢),B 机后续 key get 走 B 机 vaultKey 解密。
|
|
86
|
+
* 历史 store 里残留的明文 secret 也会被本步统一收紧成密文。
|
|
87
|
+
*/
|
|
88
|
+
async function resealSecretsForImport(payload) {
|
|
89
|
+
const sections = payload.sections || [];
|
|
90
|
+
const entries = payload.entries || [];
|
|
91
|
+
const { key: vaultKey } = await resolveVaultKey();
|
|
92
|
+
|
|
93
|
+
const newEntries = [];
|
|
94
|
+
let resealed = 0;
|
|
95
|
+
for (const e of entries) {
|
|
96
|
+
const sec = sections.find((s) => s.id === e.section);
|
|
97
|
+
if (!sec || !e.values) { newEntries.push(e); continue; }
|
|
98
|
+
const nextValues = { ...e.values };
|
|
99
|
+
let touched = false;
|
|
100
|
+
for (const def of sec.fields || []) {
|
|
101
|
+
if (!isSensitiveField(sec, def.key)) continue;
|
|
102
|
+
const v = nextValues[def.key];
|
|
103
|
+
// 已是本机 vaultKey 加密形态就跳过;明文或旧 kid 都重加密
|
|
104
|
+
if (isCipherBlob(v) && !v.kid) continue;
|
|
105
|
+
const plain = isCipherBlob(v) ? decryptValue(vaultKey, v) : (typeof v === 'string' ? v : null);
|
|
106
|
+
if (plain === null) continue;
|
|
107
|
+
nextValues[def.key] = encryptValue(vaultKey, plain);
|
|
108
|
+
touched = true; resealed++;
|
|
109
|
+
}
|
|
110
|
+
newEntries.push(touched ? { ...e, values: nextValues } : e);
|
|
111
|
+
}
|
|
112
|
+
return { ...payload, entries: newEntries, _resealed: resealed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 把台账指向的物理原件读成 {rel → {b64, sha256}}(--with-files 用)。
|
|
117
|
+
* 台账是权威:磁盘上缺的原件记入 missing,不嵌入、不报错——导出不因缺盘而失败。
|
|
118
|
+
*/
|
|
119
|
+
async function packFiles(store) {
|
|
120
|
+
const map = {};
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
const missing = [];
|
|
123
|
+
for (const rec of store.files) {
|
|
124
|
+
const abs = fileRecordAbs(rec);
|
|
125
|
+
if (!(await pathExists(abs))) { missing.push(rec.rel); continue; }
|
|
126
|
+
const buf = await fsp.readFile(abs);
|
|
127
|
+
map[rec.rel] = { b64: buf.toString('base64'), sha256: createHash('sha256').update(buf).digest('hex') };
|
|
128
|
+
bytes += buf.length;
|
|
129
|
+
}
|
|
130
|
+
return { map, bytes, missing };
|
|
131
|
+
}
|
|
132
|
+
|
|
69
133
|
/** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
|
|
70
134
|
export async function buildBackup(ctx) {
|
|
71
135
|
const password = passwordFrom(ctx);
|
|
136
|
+
const withFiles = ctx['with-files'] === true;
|
|
72
137
|
const store = await loadStore();
|
|
73
138
|
const raw = {
|
|
74
139
|
version: store.version,
|
|
75
140
|
settings: store.settings,
|
|
76
141
|
sections: store.sections,
|
|
77
142
|
entries: store.entries,
|
|
143
|
+
files: store.files, // v3:台账恒随包(元数据很小;缺了它导入即清空附件域)
|
|
78
144
|
exports: store.exports,
|
|
79
145
|
};
|
|
80
146
|
|
|
@@ -94,8 +160,16 @@ export async function buildBackup(ctx) {
|
|
|
94
160
|
const counts = {
|
|
95
161
|
sections: store.sections.length,
|
|
96
162
|
entries: store.entries.length,
|
|
163
|
+
files: store.files.length,
|
|
97
164
|
unsealed: payload._unsealed || 0,
|
|
98
165
|
};
|
|
166
|
+
if (withFiles) {
|
|
167
|
+
const packed = await packFiles(store);
|
|
168
|
+
archive.filesData = encryptValue(wrapKey, JSON.stringify(packed.map));
|
|
169
|
+
counts.filesEmbedded = Object.keys(packed.map).length;
|
|
170
|
+
counts.filesBytes = packed.bytes;
|
|
171
|
+
counts.filesMissing = packed.missing.length;
|
|
172
|
+
}
|
|
99
173
|
if (ctx['dry-run']) return { dryRun: true, counts };
|
|
100
174
|
return { archive, counts };
|
|
101
175
|
}
|
|
@@ -130,8 +204,8 @@ function parseArchive(text) {
|
|
|
130
204
|
if (!archive || archive.app !== 'nx-sk' || archive.kind !== BACKUP_KIND || !archive.data) {
|
|
131
205
|
throw badInput(`不是 nx-sk 的加密备份文件(缺少 app/kind 标记)`);
|
|
132
206
|
}
|
|
133
|
-
if (archive.v
|
|
134
|
-
throw badInput(`备份文件版本不受支持: v${archive.v}
|
|
207
|
+
if (!SUPPORTED_VERSIONS.has(archive.v)) {
|
|
208
|
+
throw badInput(`备份文件版本不受支持: v${archive.v}(本工具支持 v2 / v3)`);
|
|
135
209
|
}
|
|
136
210
|
return archive;
|
|
137
211
|
}
|
|
@@ -140,9 +214,9 @@ function parseArchive(text) {
|
|
|
140
214
|
export function decryptBackup(text, password) {
|
|
141
215
|
const archive = parseArchive(text);
|
|
142
216
|
if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
|
|
217
|
+
const key = deriveKey(password, archive.salt);
|
|
143
218
|
let payload;
|
|
144
219
|
try {
|
|
145
|
-
const key = deriveKey(password, archive.salt);
|
|
146
220
|
payload = JSON.parse(decryptValue(key, archive.data));
|
|
147
221
|
} catch (e) {
|
|
148
222
|
if (e && (e.code === 'BLOCKED' || /解密失败/.test(String(e.message)))) {
|
|
@@ -153,40 +227,323 @@ export function decryptBackup(text, password) {
|
|
|
153
227
|
if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
|
|
154
228
|
throw badInput('备份内容缺少 sections / entries,无法恢复。');
|
|
155
229
|
}
|
|
156
|
-
|
|
230
|
+
// 附件原件块(v3 --with-files 才有):跟着同一把 wrapKey 走,损坏就整体拒绝
|
|
231
|
+
let filesData = null;
|
|
232
|
+
if (archive.filesData) {
|
|
233
|
+
try {
|
|
234
|
+
filesData = JSON.parse(decryptValue(key, archive.filesData));
|
|
235
|
+
} catch {
|
|
236
|
+
throw blocked('备份的附件数据解密失败:文件可能已损坏。');
|
|
237
|
+
}
|
|
238
|
+
if (!filesData || typeof filesData !== 'object' || Array.isArray(filesData)) filesData = null;
|
|
239
|
+
}
|
|
240
|
+
return { payload, createdAt: archive.createdAt, filesData };
|
|
157
241
|
}
|
|
158
242
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
243
|
+
function tryDecrypt(key, blob) {
|
|
244
|
+
try { return decryptValue(key, blob); } catch { return undefined; }
|
|
245
|
+
}
|
|
162
246
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
247
|
+
/**
|
|
248
|
+
* 报告用值摘要:敏感字段一律 maskValue(sk-1******cdef 形态),非敏感截 24 字符、
|
|
249
|
+
* 换行折空格。冲突清单会进 CLI 输出与面板 Modal——**密钥明文绝不进报告**。
|
|
250
|
+
*/
|
|
251
|
+
function summarizeValue(section, key, v, vaultKey) {
|
|
252
|
+
if (isBlank(v)) return '';
|
|
253
|
+
if (isSensitiveField(section, key)) {
|
|
254
|
+
let plain = v;
|
|
255
|
+
if (isCipherBlob(v)) {
|
|
256
|
+
const p = tryDecrypt(vaultKey, v);
|
|
257
|
+
if (p === undefined) return '[无法解密]';
|
|
258
|
+
plain = p;
|
|
259
|
+
}
|
|
260
|
+
return maskValue(String(plain));
|
|
171
261
|
}
|
|
262
|
+
let s;
|
|
263
|
+
if (Array.isArray(v)) s = formatValue(v);
|
|
264
|
+
else if (v !== null && typeof v === 'object') s = JSON.stringify(v);
|
|
265
|
+
else s = formatValue(v);
|
|
266
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
267
|
+
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat;
|
|
268
|
+
}
|
|
172
269
|
|
|
173
|
-
|
|
174
|
-
|
|
270
|
+
/** 敏感字段落值前用本机 vaultKey 重加密;解不开的旧密文原样跳过(不写坏数据)。 */
|
|
271
|
+
function sealSensitive(sec, key, v, vaultKey, report) {
|
|
272
|
+
if (!isSensitiveField(sec, key)) return v;
|
|
273
|
+
let plain = v;
|
|
274
|
+
if (isCipherBlob(v)) {
|
|
275
|
+
const p = tryDecrypt(vaultKey, v);
|
|
276
|
+
if (p === undefined) return v;
|
|
277
|
+
plain = p;
|
|
278
|
+
}
|
|
279
|
+
report.resealed += 1;
|
|
280
|
+
return encryptValue(vaultKey, plain);
|
|
281
|
+
}
|
|
175
282
|
|
|
176
|
-
|
|
283
|
+
/**
|
|
284
|
+
* 备份负载 → 归一化的「来料」形状:栏目分组不变量、files 台账、设置默认值
|
|
285
|
+
* 一并走 normalize() 收口;老版本 payload 顺带完成迁移。
|
|
286
|
+
*/
|
|
287
|
+
function normalizeIncoming(payload) {
|
|
288
|
+
return normalize({
|
|
177
289
|
version: payload.version,
|
|
178
|
-
settings: payload.settings,
|
|
290
|
+
settings: payload.settings && typeof payload.settings === 'object' ? payload.settings : {},
|
|
179
291
|
sections: payload.sections,
|
|
180
292
|
entries: payload.entries,
|
|
181
|
-
|
|
293
|
+
files: Array.isArray(payload.files) ? payload.files : [],
|
|
182
294
|
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 合并计划:local + 备份来料 → 纯数据计划(不落盘、不碰物理文件)。
|
|
299
|
+
*
|
|
300
|
+
* 只增不减:
|
|
301
|
+
* - 栏目:缺 → 新增;同 id → mergeTemplate 把备份多出的分组/字段补进来,本地独有一律保留;
|
|
302
|
+
* kv 标记不一致 → 不动形状、只警示。
|
|
303
|
+
* - 条目:kv 栏目按 title(键名;跨机器 id 不可靠)匹配、其余按 id;缺 → 新增;
|
|
304
|
+
* 同条目 → 只补空字段,冲突保留本地并列清单。
|
|
305
|
+
* - 字段合并遍历「本地字典 ∪ 备份 values」的 key 并集——字典不拦写入是核心不变量,
|
|
306
|
+
* 备份带本地没有的键也必须保值带过来,静默丢弃就是数据丢失。
|
|
307
|
+
* - settings 只补本地没有的键('' 是有意义的空,不补);exports 历史有意不动。
|
|
308
|
+
* - 附件台账按 id 合并(不做 sha 去重——条目存的是 id,去重会断链;同行共指一文件无害)。
|
|
309
|
+
*/
|
|
310
|
+
function buildMergePlan(local, incoming, vaultKey) {
|
|
311
|
+
const report = {
|
|
312
|
+
addedSections: [], addedEntries: [], addedEntriesTotal: 0,
|
|
313
|
+
filled: [], filledTotal: 0,
|
|
314
|
+
conflicts: [], conflictsTotal: 0,
|
|
315
|
+
structureAdded: [], structureOnlyLocal: [], kvMismatch: [],
|
|
316
|
+
settingsFilled: [], resealed: 0,
|
|
317
|
+
};
|
|
318
|
+
const sections = local.sections.map((s) => ({ ...s }));
|
|
319
|
+
const entries = local.entries.map((e) => ({ ...e, values: { ...e.values } }));
|
|
320
|
+
const files = local.files.map((f) => ({ ...f }));
|
|
321
|
+
|
|
322
|
+
// ① 栏目:缺 → 新增;同 id → 只增补形状(永不删)
|
|
323
|
+
for (const bs of incoming.sections) {
|
|
324
|
+
const ls = sections.find((s) => s.id === bs.id);
|
|
325
|
+
if (!ls) {
|
|
326
|
+
sections.push({ ...bs });
|
|
327
|
+
report.addedSections.push({
|
|
328
|
+
id: bs.id, title: bs.title, fields: bs.fields.length,
|
|
329
|
+
entries: incoming.entries.filter((e) => e.section === bs.id).length,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if ((ls.kv === true) !== (bs.kv === true)) {
|
|
334
|
+
report.kvMismatch.push(ls.id);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const { groups, fields, addedGroups, addedFields } = mergeTemplate(ls, bs);
|
|
338
|
+
if (addedGroups.length || addedFields.length) {
|
|
339
|
+
ls.groups = groups;
|
|
340
|
+
ls.fields = fields;
|
|
341
|
+
ls.updatedAt = nowIso();
|
|
342
|
+
report.structureAdded.push({ id: ls.id, title: ls.title, groups: addedGroups, fields: addedFields });
|
|
343
|
+
}
|
|
344
|
+
const backupKeys = new Set(bs.fields.map((f) => f.key));
|
|
345
|
+
const onlyLocal = ls.fields.filter((f) => !backupKeys.has(f.key)).map((f) => f.key);
|
|
346
|
+
if (onlyLocal.length) {
|
|
347
|
+
report.structureOnlyLocal.push({ id: ls.id, title: ls.title, fields: onlyLocal });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
183
350
|
|
|
351
|
+
// ② 条目:kv 按 title、其余按 id;缺 → 新增;同 → 补空/记冲突
|
|
352
|
+
for (const be of incoming.entries) {
|
|
353
|
+
const sec = sections.find((s) => s.id === be.section);
|
|
354
|
+
if (!sec) continue; // 备份自洽时不会发生;防御分支
|
|
355
|
+
const pool = entries.filter((e) => e.section === sec.id);
|
|
356
|
+
const le = sec.kv
|
|
357
|
+
? pool.find((e) => e.title === be.title)
|
|
358
|
+
: pool.find((e) => e.id === be.id);
|
|
359
|
+
|
|
360
|
+
if (!le) {
|
|
361
|
+
const id = entries.some((e) => e.id === be.id) ? newId('e') : be.id;
|
|
362
|
+
const fresh = { ...be, id, updatedAt: nowIso(), values: {} };
|
|
363
|
+
for (const [k, v] of Object.entries(be.values || {})) {
|
|
364
|
+
if (isBlank(v)) continue;
|
|
365
|
+
fresh.values[k] = sealSensitive(sec, k, v, vaultKey, report);
|
|
366
|
+
}
|
|
367
|
+
entries.push(fresh);
|
|
368
|
+
report.addedEntriesTotal += 1;
|
|
369
|
+
if (report.addedEntries.length < REPORT_CAP) {
|
|
370
|
+
report.addedEntries.push({ id, section: sec.id, title: be.title || id });
|
|
371
|
+
}
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 已匹配:本地字典 ∪ 备份 values 的 key 并集(字典不拦写入——见函数头注释)
|
|
376
|
+
const keys = new Set([...sec.fields.map((f) => f.key), ...Object.keys(be.values || {})]);
|
|
377
|
+
let touched = false;
|
|
378
|
+
for (const key of keys) {
|
|
379
|
+
const bv = (be.values || {})[key];
|
|
380
|
+
if (isBlank(bv)) continue;
|
|
381
|
+
const lv = le.values[key];
|
|
382
|
+
if (isBlank(lv)) {
|
|
383
|
+
le.values[key] = sealSensitive(sec, key, bv, vaultKey, report);
|
|
384
|
+
touched = true;
|
|
385
|
+
report.filledTotal += 1;
|
|
386
|
+
if (report.filled.length < REPORT_CAP) {
|
|
387
|
+
const def = sec.fields.find((f) => f.key === key);
|
|
388
|
+
report.filled.push({
|
|
389
|
+
section: sec.id, entry: le.title || le.id, field: key,
|
|
390
|
+
label: def ? def.label : key,
|
|
391
|
+
value: summarizeValue(sec, key, bv, vaultKey),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const lPlain = isCipherBlob(lv) ? tryDecrypt(vaultKey, lv) : lv;
|
|
397
|
+
if (JSON.stringify(lPlain) !== JSON.stringify(bv)) {
|
|
398
|
+
report.conflictsTotal += 1;
|
|
399
|
+
if (report.conflicts.length < REPORT_CAP) {
|
|
400
|
+
const def = sec.fields.find((f) => f.key === key);
|
|
401
|
+
report.conflicts.push({
|
|
402
|
+
section: sec.id, entry: le.title || le.id, field: key,
|
|
403
|
+
label: def ? def.label : key,
|
|
404
|
+
localValue: summarizeValue(sec, key, lPlain, vaultKey),
|
|
405
|
+
backupValue: summarizeValue(sec, key, bv, vaultKey),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (touched) le.updatedAt = nowIso();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ③ 设置:只补本地没有的键;'' = 用默认值,是**有意义的空**,不补
|
|
414
|
+
const settings = { ...local.settings };
|
|
415
|
+
for (const [k, v] of Object.entries(incoming.settings)) {
|
|
416
|
+
if (settings[k] === undefined && !isBlank(v)) {
|
|
417
|
+
settings[k] = v;
|
|
418
|
+
report.settingsFilled.push(k);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ④ 附件台账:按 id 合并;已有 id 一律沿用本地记录
|
|
423
|
+
const filesToAdd = [];
|
|
424
|
+
let filesKept = 0;
|
|
425
|
+
for (const br of incoming.files) {
|
|
426
|
+
if (files.some((r) => r.id === br.id)) { filesKept += 1; continue; }
|
|
427
|
+
files.push(br);
|
|
428
|
+
filesToAdd.push(br);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { sections, entries, settings, files, filesToAdd, filesKept, report };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 把归档里携带的附件原件落到磁盘(只处理带了二进制的记录)。
|
|
436
|
+
* sha 校验通过才写;磁盘已有且完好 → 跳过;内容不同 → **磁盘为准,不覆盖**。
|
|
437
|
+
* 物理文件先行(仓库惯例):若后续 mutateStore 抛错,最多留下无台账引用的孤儿文件——无害。
|
|
438
|
+
*/
|
|
439
|
+
async function materializeFiles(filesData, finalFiles, addedIds) {
|
|
440
|
+
const stats = {
|
|
441
|
+
written: 0, healed: 0, skippedExisting: 0, checksumMismatch: 0,
|
|
442
|
+
missingBinary: [], missingBinaryTotal: 0, bytes: 0,
|
|
443
|
+
};
|
|
444
|
+
if (!filesData) {
|
|
445
|
+
for (const rec of finalFiles) {
|
|
446
|
+
if (addedIds.has(rec.id)) { stats.missingBinary.push(rec.rel); stats.missingBinaryTotal += 1; }
|
|
447
|
+
}
|
|
448
|
+
return stats;
|
|
449
|
+
}
|
|
450
|
+
for (const rec of finalFiles) {
|
|
451
|
+
const packed = filesData[rec.rel];
|
|
452
|
+
if (!packed || !packed.b64) {
|
|
453
|
+
if (addedIds.has(rec.id)) { stats.missingBinary.push(rec.rel); stats.missingBinaryTotal += 1; }
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const buf = Buffer.from(packed.b64, 'base64');
|
|
457
|
+
const sha = createHash('sha256').update(buf).digest('hex');
|
|
458
|
+
if (sha !== rec.sha256) { stats.checksumMismatch += 1; continue; }
|
|
459
|
+
const abs = fileRecordAbs(rec);
|
|
460
|
+
if (await pathExists(abs)) {
|
|
461
|
+
if ((await hashFile(abs)) === rec.sha256) continue; // 已在且完好
|
|
462
|
+
stats.skippedExisting += 1; // 内容不同:磁盘为准
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
await writeAtomic(abs, buf);
|
|
466
|
+
stats.bytes += buf.length;
|
|
467
|
+
if (addedIds.has(rec.id)) stats.written += 1;
|
|
468
|
+
else stats.healed += 1;
|
|
469
|
+
}
|
|
470
|
+
return stats;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 恢复 / 合并导入。
|
|
475
|
+
* 缺省(merge):按「只增不减」并入当前数据,出完整报告;--replace 才整份替换。
|
|
476
|
+
* 两种方式都先留安全快照。
|
|
477
|
+
*/
|
|
478
|
+
export async function restoreBackup({ content, password, replace, 'dry-run': dryRun }) {
|
|
479
|
+
const { payload, createdAt, filesData } = decryptBackup(String(content ?? ''), password);
|
|
480
|
+
const backupCreatedAt = createdAt || '';
|
|
481
|
+
const filesInBackup = Array.isArray(payload.files) ? payload.files.length : 0;
|
|
482
|
+
|
|
483
|
+
if (replace) {
|
|
484
|
+
// —— 整份替换(旧行为):reseal → 快照 → saveStore(v3 起带上台账与原件)——
|
|
485
|
+
const resealed = await resealSecretsForImport(payload);
|
|
486
|
+
const finalFiles = (payload.files || []).map(normalizeFileRecord).filter(Boolean);
|
|
487
|
+
if (dryRun) {
|
|
488
|
+
return {
|
|
489
|
+
status: 'skipped', dryRun: true, mode: 'replace', backupCreatedAt,
|
|
490
|
+
sections: payload.sections.length, entries: payload.entries.length,
|
|
491
|
+
filesInBackup, resealed: resealed._resealed || 0,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const safety = await snapshotStore('before-restore');
|
|
495
|
+
const files = await materializeFiles(filesData, finalFiles, new Set(finalFiles.map((r) => r.id)));
|
|
496
|
+
await saveStore({
|
|
497
|
+
version: resealed.version,
|
|
498
|
+
settings: resealed.settings,
|
|
499
|
+
sections: resealed.sections,
|
|
500
|
+
entries: resealed.entries,
|
|
501
|
+
files: finalFiles,
|
|
502
|
+
exports: Array.isArray(resealed.exports) ? resealed.exports : [],
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
status: 'restored', mode: 'replace', backupCreatedAt,
|
|
506
|
+
sections: resealed.sections.length,
|
|
507
|
+
entries: resealed.entries.length,
|
|
508
|
+
resealed: resealed._resealed || 0,
|
|
509
|
+
filesInBackup, filesStats: files,
|
|
510
|
+
safetySnapshot: displayPath(safety),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// —— 合并(缺省):先出纯计划,dry-run 即返回;落盘时物理文件先行 → mutateStore ——
|
|
515
|
+
const local = await loadStore();
|
|
516
|
+
const incoming = normalizeIncoming(payload);
|
|
517
|
+
const { key: vaultKey } = await resolveVaultKey();
|
|
518
|
+
const plan = buildMergePlan(local, incoming, vaultKey);
|
|
519
|
+
plan.report.exportsSkipped = Array.isArray(payload.exports) ? payload.exports.length : 0;
|
|
520
|
+
|
|
521
|
+
if (dryRun) {
|
|
522
|
+
return {
|
|
523
|
+
status: 'skipped', dryRun: true, mode: 'merge', backupCreatedAt,
|
|
524
|
+
sections: payload.sections.length, entries: payload.entries.length,
|
|
525
|
+
filesInBackup, ...plan.report,
|
|
526
|
+
files: { kept: plan.filesKept, toAdd: plan.filesToAdd.length },
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const safety = await snapshotStore('before-import');
|
|
531
|
+
const addedIds = new Set(plan.filesToAdd.map((r) => r.id));
|
|
532
|
+
const files = await materializeFiles(filesData, plan.files, addedIds);
|
|
533
|
+
await mutateStore((s) => {
|
|
534
|
+
s.sections = plan.sections;
|
|
535
|
+
s.entries = plan.entries;
|
|
536
|
+
s.settings = plan.settings;
|
|
537
|
+
s.files = plan.files; // exports 有意不动:那是本机的操作历史,不是用户数据
|
|
538
|
+
});
|
|
184
539
|
return {
|
|
185
|
-
status: '
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
540
|
+
status: 'ok', mode: 'merge', backupCreatedAt,
|
|
541
|
+
...plan.report,
|
|
542
|
+
files: { kept: plan.filesKept, toAdd: plan.filesToAdd.length, ...files },
|
|
543
|
+
counts: {
|
|
544
|
+
sectionsBefore: local.sections.length, sectionsAfter: plan.sections.length,
|
|
545
|
+
entriesBefore: local.entries.length, entriesAfter: plan.entries.length,
|
|
546
|
+
},
|
|
190
547
|
safetySnapshot: displayPath(safety),
|
|
191
548
|
};
|
|
192
549
|
}
|
|
@@ -201,33 +558,121 @@ export async function importBackup(ctx) {
|
|
|
201
558
|
return restoreBackup({
|
|
202
559
|
content: text,
|
|
203
560
|
password: ctx.password ?? process.env[ENV_BACKUP_PASSWORD],
|
|
561
|
+
replace: ctx.replace === true,
|
|
204
562
|
'dry-run': ctx['dry-run'],
|
|
205
563
|
});
|
|
206
564
|
}
|
|
207
565
|
|
|
208
566
|
export function renderBackup(d) {
|
|
567
|
+
const fileLine = () => {
|
|
568
|
+
if (d.files == null) return null;
|
|
569
|
+
if (d.filesEmbedded != null) {
|
|
570
|
+
return `附件: 台账 ${d.files} 条;嵌入原件 ${d.filesEmbedded} 个(共 ${d.filesBytes} 字节)`
|
|
571
|
+
+ (d.filesMissing ? `、${d.filesMissing} 个在磁盘上缺失未嵌入` : '');
|
|
572
|
+
}
|
|
573
|
+
return `附件: 台账 ${d.files} 条(未嵌原件;要连文件一起备请加 --with-files)`;
|
|
574
|
+
};
|
|
209
575
|
if (d.status === 'skipped') {
|
|
210
576
|
return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
|
|
211
|
-
+ (d.unsealed ? `\n含明文保存的密钥字段: ${d.unsealed} 个(其他机器用本次口令可解开)` : '')
|
|
577
|
+
+ (d.unsealed ? `\n含明文保存的密钥字段: ${d.unsealed} 个(其他机器用本次口令可解开)` : '')
|
|
578
|
+
+ (fileLine() ? `\n${fileLine()}` : '');
|
|
212
579
|
}
|
|
213
580
|
return [
|
|
214
581
|
`已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
|
|
215
582
|
`文件: ${d.file} ${d.bytes} 字节`,
|
|
216
583
|
`内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
217
584
|
`本次解开为明文的密钥字段: ${d.unsealed || 0} 个(其他机器用本次口令可解开)`,
|
|
218
|
-
|
|
219
|
-
|
|
585
|
+
fileLine(),
|
|
586
|
+
`恢复时需要输入本次设置的口令。缺省导入为「合并」;要整份替换用 --replace。`,
|
|
587
|
+
].filter(Boolean).join('\n');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderConflictLine(c) {
|
|
591
|
+
return ` - ${c.section} / ${c.entry} · ${c.label}(${c.field}): 本地 ${c.localValue || '(空)'} | 备份 ${c.backupValue || '(空)'}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function renderFilesStats(f) {
|
|
595
|
+
if (!f) return null;
|
|
596
|
+
const parts = [];
|
|
597
|
+
if (f.written) parts.push(`落盘原件 ${f.written} 个`);
|
|
598
|
+
if (f.healed) parts.push(`补齐缺失原件 ${f.healed} 个`);
|
|
599
|
+
if (f.bytes) parts.push(`共 ${f.bytes} 字节`);
|
|
600
|
+
if (f.skippedExisting) parts.push(`磁盘已有但内容不同、保留磁盘 ${f.skippedExisting} 个`);
|
|
601
|
+
if (f.checksumMismatch) parts.push(`校验不符被拒 ${f.checksumMismatch} 个`);
|
|
602
|
+
if (f.missingBinaryTotal) parts.push(`${f.missingBinaryTotal} 条台账无原件(备份未携带,预览/下载会报错)`);
|
|
603
|
+
return parts.length ? `附件: ${parts.join(';')}` : '附件: 台账已并入,原件均已就位';
|
|
220
604
|
}
|
|
221
605
|
|
|
222
606
|
export function renderRestore(d) {
|
|
223
|
-
if (d.
|
|
224
|
-
|
|
225
|
-
|
|
607
|
+
if (d.dryRun) {
|
|
608
|
+
if (d.mode === 'replace') {
|
|
609
|
+
return [
|
|
610
|
+
'试运行(未改动当前数据)',
|
|
611
|
+
`备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目,生成于 ${d.backupCreatedAt || '未知'}`,
|
|
612
|
+
`导入模式: 整份替换——当前数据将被备份整体覆盖(含 ${d.filesInBackup} 条附件台账)`,
|
|
613
|
+
].join('\n');
|
|
614
|
+
}
|
|
615
|
+
return [
|
|
616
|
+
'试运行(未改动当前数据)',
|
|
617
|
+
`备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目,生成于 ${d.backupCreatedAt || '未知'}`,
|
|
618
|
+
'导入模式: 合并(缺省)——预计'
|
|
619
|
+
+ `新增栏目 ${(d.addedSections || []).length} 个、新增条目 ${d.addedEntriesTotal || 0} 条、`
|
|
620
|
+
+ `补空 ${d.filledTotal || 0} 处、冲突 ${d.conflictsTotal || 0} 处、`
|
|
621
|
+
+ `补结构 ${d.structureAdded.length ? d.structureAdded.reduce((n, x) => n + x.fields.length, 0) : 0} 个字段`,
|
|
622
|
+
'(--replace 则整份替换:当前数据将被备份整体覆盖)',
|
|
623
|
+
].join('\n');
|
|
226
624
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
625
|
+
|
|
626
|
+
if (d.mode === 'replace') {
|
|
627
|
+
return [
|
|
628
|
+
`已从加密备份整份替换恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
|
|
629
|
+
renderFilesStats(d.filesStats),
|
|
630
|
+
`用本机密钥库重新加密的密钥字段: ${d.resealed || 0} 个`,
|
|
631
|
+
`备份生成于: ${d.backupCreatedAt || '未知'}`,
|
|
632
|
+
`恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
|
|
633
|
+
].filter(Boolean).join('\n');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 合并报告:只增不减的完整交代
|
|
637
|
+
const lines = ['已按「合并」导入备份(只增补、不覆盖本地已有值,本地结构未被删改)'];
|
|
638
|
+
lines.push(
|
|
639
|
+
`新增栏目 ${(d.addedSections || []).length} 个 / 新增条目 ${d.addedEntriesTotal || 0} 条;`
|
|
640
|
+
+ `补空 ${d.filledTotal || 0} 处;冲突 ${d.conflictsTotal || 0} 处(保留本地值,请手动核对)`,
|
|
641
|
+
);
|
|
642
|
+
if ((d.addedSections || []).length) {
|
|
643
|
+
lines.push('新增栏目:');
|
|
644
|
+
for (const s of d.addedSections) lines.push(` - ${s.id}(${s.title}): ${s.fields} 个字段 / ${s.entries} 条条目`);
|
|
645
|
+
}
|
|
646
|
+
if (d.filledTotal) {
|
|
647
|
+
lines.push('补空明细:');
|
|
648
|
+
for (const f of d.filled) lines.push(` - ${f.section} / ${f.entry} · ${f.label}(${f.field}) = ${f.value || '(空)'}`);
|
|
649
|
+
if (d.filledTotal > d.filled.length) lines.push(` …共 ${d.filledTotal} 处`);
|
|
650
|
+
}
|
|
651
|
+
if (d.conflictsTotal) {
|
|
652
|
+
lines.push('冲突明细(两边都有值且不同,已保留本地):');
|
|
653
|
+
for (const c of d.conflicts) lines.push(renderConflictLine(c));
|
|
654
|
+
if (d.conflictsTotal > d.conflicts.length) lines.push(` …共 ${d.conflictsTotal} 处`);
|
|
655
|
+
}
|
|
656
|
+
if ((d.structureAdded || []).length) {
|
|
657
|
+
lines.push('结构补齐(只增):');
|
|
658
|
+
for (const s of d.structureAdded) {
|
|
659
|
+
lines.push(` - ${s.id}: +${s.fields.length} 个字段 [${s.fields.join(', ')}]`
|
|
660
|
+
+ (s.groups.length ? `,+分组 [${s.groups.join(', ')}]` : ''));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if ((d.structureOnlyLocal || []).length) {
|
|
664
|
+
lines.push('本地独有字段(备份里没有,已原样保留):');
|
|
665
|
+
for (const s of d.structureOnlyLocal) lines.push(` - ${s.id}: [${s.fields.join(', ')}]`);
|
|
666
|
+
}
|
|
667
|
+
if ((d.kvMismatch || []).length) {
|
|
668
|
+
lines.push(`kv 标记与备份不一致、未合并结构的栏目: ${d.kvMismatch.join(', ')}`);
|
|
669
|
+
}
|
|
670
|
+
if ((d.settingsFilled || []).length) lines.push(`设置补空: ${d.settingsFilled.join(', ')}`);
|
|
671
|
+
if (d.exportsSkipped) lines.push(`备份里的导出历史 ${d.exportsSkipped} 条未并入(保留本机记录)`);
|
|
672
|
+
const f = renderFilesStats(d.files);
|
|
673
|
+
if (f) lines.push(f);
|
|
674
|
+
if (d.resealed) lines.push(`用本机密钥库重新加密的密钥字段: ${d.resealed} 个`);
|
|
675
|
+
lines.push(`备份生成于: ${d.backupCreatedAt || '未知'}`);
|
|
676
|
+
lines.push(`导入前的当前数据已留安全快照: ${d.safetySnapshot}`);
|
|
677
|
+
return lines.join('\n');
|
|
678
|
+
}
|