@xiaoyuyu6420/dsh-backup 0.14.1 → 0.14.2

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/lib/encrypt.js CHANGED
@@ -23,6 +23,10 @@ import {
23
23
  randomBytes,
24
24
  scrypt as scryptCb,
25
25
  } from 'node:crypto';
26
+ import { createReadStream, createWriteStream } from 'node:fs';
27
+ import { rm } from 'node:fs/promises';
28
+ import { Transform } from 'node:stream';
29
+ import { pipeline } from 'node:stream/promises';
26
30
  import { promisify } from 'node:util';
27
31
 
28
32
  const scrypt = promisify(scryptCb);
@@ -91,15 +95,10 @@ export async function encryptBuffer(plaintext, password) {
91
95
  }
92
96
 
93
97
  /**
94
- * 解密一段密文。口令错误 / 密文被篡改时 AES-GCM 认证失败,抛出口令错误人话报错
95
- * (调用方可直接展示);未知版本 / 未知算法 / 边车字段缺失一律拒绝,不猜着解。
96
- *
97
- * @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
98
- * @param {object} sidecar `.encrypted.json` 解析出的对象
99
- * @param {string} password 口令(内存态)
100
- * @returns {Promise<Buffer>} 明文(tar.gz 字节)
98
+ * 校验边车并派生出解开这段密文所需的 { key, nonce, authTag }。
99
+ * 版本/算法/字段缺失一律拒绝(宁可不恢复,也不拿不认识的参数猜着解)。
101
100
  */
102
- export async function decryptBuffer(ciphertext, sidecar, password) {
101
+ async function unlock(sidecar, password) {
103
102
  if (typeof password !== 'string' || !password) {
104
103
  throw new Error('这份备份已加密,恢复需要口令(口令只经内存传入,绝不写盘)');
105
104
  }
@@ -120,16 +119,116 @@ export async function decryptBuffer(ciphertext, sidecar, password) {
120
119
  throw new Error('加密边车字段缺失(salt/nonce/authTag),无法解密');
121
120
  }
122
121
  const key = await deriveKey(password, { ...(sidecar.kdfParams || KDF_PARAMS), salt });
122
+ return { key, nonce, authTag };
123
+ }
124
+
125
+ /** GCM 认证失败的统一人话报错(不泄露具体原因)。 */
126
+ function authFailed(err) {
127
+ if (/unable to authenticate|authentication/i.test(String(err && err.message ? err.message : err))) {
128
+ return new Error('口令错误或归档已损坏(AES-GCM 认证失败)——请核对口令后重试');
129
+ }
130
+ return err;
131
+ }
132
+
133
+ /**
134
+ * 解密一段密文(内存版,供小体积数据/测试用)。口令错误 / 密文被篡改时 AES-GCM
135
+ * 认证失败,抛出口令错误人话报错(调用方可直接展示)。
136
+ *
137
+ * @param {Buffer|Uint8Array} ciphertext 密文(归档文件内容)
138
+ * @param {object} sidecar `.encrypted.json` 解析出的对象
139
+ * @param {string} password 口令(内存态)
140
+ * @returns {Promise<Buffer>} 明文(tar.gz 字节)
141
+ */
142
+ export async function decryptBuffer(ciphertext, sidecar, password) {
143
+ const { key, nonce, authTag } = await unlock(sidecar, password);
123
144
  const decipher = createDecipheriv(CIPHER, key, nonce);
124
145
  decipher.setAuthTag(authTag);
125
146
  const body = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext);
126
147
  try {
127
148
  return Buffer.concat([decipher.update(body), decipher.final()]);
128
149
  } catch (err) {
129
- // GCM final() 认证失败:口令错或密文被改。两条都指向同一句人话,不泄露具体原因。
130
- if (/unable to authenticate|authentication/i.test(String(err && err.message ? err.message : err))) {
131
- throw new Error('口令错误或归档已损坏(AES-GCM 认证失败)——请核对口令后重试');
132
- }
150
+ throw authFailed(err);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 加密文件:**分块流式**,不整包入内存(归档可以远超 Buffer 上限)。
156
+ * 调用方负责把输出写到临时路径后自己 `rename` 覆盖(本函数只保证「要么完整
157
+ * 密文、要么失败且不留半截文件」)。
158
+ *
159
+ * @param {string} inPath 明文文件
160
+ * @param {string} outPath 密文输出文件
161
+ * @param {string} password 口令
162
+ * @returns {Promise<{ sidecar: object, ciphertextSize: number }>}
163
+ */
164
+ export async function encryptFile(inPath, outPath, password) {
165
+ if (typeof password !== 'string' || !password) {
166
+ throw new Error('加密需要非空口令(口令只经内存传入,绝不写盘)');
167
+ }
168
+ const salt = randomBytes(SALT_BYTES);
169
+ const nonce = randomBytes(NONCE_BYTES);
170
+ const key = await deriveKey(password, { ...KDF_PARAMS, salt });
171
+ const cipher = createCipheriv(CIPHER, key, nonce);
172
+ let ciphertextSize = 0;
173
+ const counter = new Transform({
174
+ transform(chunk, _enc, done) {
175
+ ciphertextSize += chunk.length;
176
+ done(null, chunk);
177
+ },
178
+ });
179
+ try {
180
+ await pipeline(
181
+ createReadStream(inPath),
182
+ cipher,
183
+ counter,
184
+ createWriteStream(outPath, { mode: 0o600 }),
185
+ );
186
+ } catch (err) {
187
+ await rm(outPath, { force: true }).catch(() => {});
133
188
  throw err;
134
189
  }
190
+ const authTag = cipher.getAuthTag();
191
+ return {
192
+ ciphertextSize,
193
+ sidecar: {
194
+ version: ENCRYPT_VERSION,
195
+ cipher: CIPHER,
196
+ kdf: KDF_PARAMS.algorithm,
197
+ kdfParams: {
198
+ N: KDF_PARAMS.N,
199
+ r: KDF_PARAMS.r,
200
+ p: KDF_PARAMS.p,
201
+ keyLen: KDF_PARAMS.keyLen,
202
+ },
203
+ salt: salt.toString('base64'),
204
+ nonce: nonce.toString('base64'),
205
+ authTag: authTag.toString('base64'),
206
+ ciphertextSize,
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * 解密文件:**分块流式**。认证通过才产出完整明文;口令错/密文被改时删除半截
213
+ * 输出并抛人话报错(绝不留下「解了一半」的明文)。
214
+ *
215
+ * @param {string} inPath 密文文件
216
+ * @param {string} outPath 明文输出文件(可能不完整;失败时已被删除)
217
+ * @param {object} sidecar `.encrypted.json` 内容
218
+ * @param {string} password 口令
219
+ */
220
+ export async function decryptFile(inPath, outPath, sidecar, password) {
221
+ const { key, nonce, authTag } = await unlock(sidecar, password);
222
+ const decipher = createDecipheriv(CIPHER, key, nonce);
223
+ decipher.setAuthTag(authTag);
224
+ try {
225
+ await pipeline(
226
+ createReadStream(inPath),
227
+ decipher,
228
+ createWriteStream(outPath, { mode: 0o600 }),
229
+ );
230
+ } catch (err) {
231
+ await rm(outPath, { force: true }).catch(() => {});
232
+ throw authFailed(err);
233
+ }
135
234
  }
package/lib/index.js CHANGED
@@ -63,7 +63,7 @@ import { basename, dirname, join, resolve } from 'node:path';
63
63
  import { StringDecoder } from 'node:string_decoder';
64
64
  import { fileURLToPath } from 'node:url';
65
65
  import * as zlib from 'node:zlib';
66
- import { encryptBuffer, decryptBuffer } from './encrypt.js';
66
+ import { decryptFile, encryptFile } from './encrypt.js';
67
67
  import { buildZip, listZip, extractZip, readZipEntry } from './zip.js';
68
68
  import { gfsProtectedSet } from './gfs.js';
69
69
  import { acquireLock, releaseLock, heartbeatLock, reclaimLock as reclaimOplock, isLockStale, LOCK_HEARTBEAT_MS } from './oplock.js';
@@ -1777,9 +1777,17 @@ export function apply(ctx, pluginConfig) {
1777
1777
  // 替换——旧版插件遇到它只会 tar 解不开,边车只增不改、不误删不误读。
1778
1778
  let encrypted = false;
1779
1779
  if (typeof opts?.password === 'string' && opts.password) {
1780
- const plain = await readFile(out);
1781
- const enc = await encryptBuffer(plain, opts.password);
1782
- await writeBinary(out, enc.ciphertext);
1780
+ // 流式加密 + 临时文件后原子替换:绝不在原路径「整包读入 → 原地覆盖」。
1781
+ // 中途崩溃只会留下完整明文或完整密文,不会留半截密文(#103 同类教训)。
1782
+ const tmpEnc = `${out}.enc-${stampNow()}.tmp`;
1783
+ let enc;
1784
+ try {
1785
+ enc = await encryptFile(out, tmpEnc, opts.password);
1786
+ await rename(tmpEnc, out);
1787
+ } catch (err) {
1788
+ await rm(tmpEnc, { force: true }).catch(() => {});
1789
+ throw err;
1790
+ }
1783
1791
  await writeOwned(`${out}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
1784
1792
  encrypted = true;
1785
1793
  }
@@ -2190,7 +2198,7 @@ export function apply(ctx, pluginConfig) {
2190
2198
  function redactSyncChannel(channel, ch) {
2191
2199
  if (!ch) return { channel, type: channel, configured: false };
2192
2200
  const out = { channel, type: ch.type, configured: true, lastPush: ch.lastPush ?? null, lastError: ch.lastError ?? null };
2193
- if (ch.type === 'webdav') out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password);
2201
+ if (ch.type === 'webdav') { out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password); }
2194
2202
  if (ch.type === 's3') { out.endpoint = ch.endpoint; out.region = ch.region; out.bucket = ch.bucket; out.pathPrefix = ch.pathPrefix || ''; out.accessKey = ch.accessKey; out.secretKeySet = Boolean(ch.secretKey); out.pathStyle = ch.pathStyle !== false; }
2195
2203
  if (ch.type === 'gist') { out.gistId = ch.gistId || ''; out.tokenSet = Boolean(ch.token); }
2196
2204
  return out;
@@ -2404,13 +2412,12 @@ export function apply(ctx, pluginConfig) {
2404
2412
  // meta.encrypted 标记了加密但边车缺失:这是不一致状态,拒绝而非猜着解
2405
2413
  throw new Error(`加密边车(.encrypted.json)缺失或损坏,无法解密——请核对备份是否完整(技术细节: ${snippet(String(err && err.message ? err.message : err), 80)})`);
2406
2414
  }
2407
- const ciphertext = await readFile(`${root}/${archiveName}`);
2408
- const plain = await decryptBuffer(ciphertext, sidecar, password);
2415
+ const tmpName = `.decrypt-${stampNow()}.tar.gz`;
2416
+ // 直接流式解密到目标临时文件:大归档不整包入内存,认证失败时不留半截明文。
2417
+ await decryptFile(`${root}/${archiveName}`, `${root}/${tmpName}`, sidecar, password);
2409
2418
  // 隐藏名 `.decrypt-<时间戳>.tar.gz`:listBackups 要求 `dsh-` 前缀,隐藏点开头
2410
2419
  // 的临时文件绝不进用户列表/轮换;位于备份目录内、相对名传 tar,规避 Windows
2411
2420
  // GNU tar 盘符冒号问题。finally 里必清理(明文 tar.gz 不能遗留)。
2412
- const tmpName = `.decrypt-${stampNow()}.tar.gz`;
2413
- await writeBinary(`${root}/${tmpName}`, plain);
2414
2421
  return { name: tmpName, cleanup: () => rm(`${root}/${tmpName}`, { force: true }) };
2415
2422
  }
2416
2423
 
@@ -3101,9 +3108,17 @@ export function apply(ctx, pluginConfig) {
3101
3108
  for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.tarballName}`, sourcePath: p.tarballPath });
3102
3109
  await buildZip(tmpPath, entryList, { signal });
3103
3110
  if (encrypted) {
3104
- const plain = await readFile(tmpPath);
3105
- const enc = await encryptBuffer(plain, password);
3106
- await writeBinary(finalPath, enc.ciphertext);
3111
+ const tmpEnc = `${finalPath}.enc-${stampNow()}.tmp`;
3112
+ let enc;
3113
+ try {
3114
+ enc = await encryptFile(tmpPath, tmpEnc, password);
3115
+ await rename(tmpEnc, finalPath);
3116
+ } catch (err) {
3117
+ await rm(tmpEnc, { force: true }).catch(() => {});
3118
+ throw err;
3119
+ } finally {
3120
+ await rm(tmpPath, { force: true }).catch(() => {});
3121
+ }
3107
3122
  await writeOwned(`${finalPath}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
3108
3123
  } else {
3109
3124
  await rename(tmpPath, finalPath);
@@ -4065,11 +4080,9 @@ export function apply(ctx, pluginConfig) {
4065
4080
  if (encrypted) {
4066
4081
  if (!password) throw new Error('这份导出已加密,需要口令才能读取(口令只经面板 RPC 内存态传入,绝不落盘)');
4067
4082
  const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8'));
4068
- const ciphertext = await readFile(src);
4069
- const plain = await decryptBuffer(ciphertext, sidecar, password);
4070
4083
  const { root } = paths();
4071
4084
  const tmp = `${root}/.export-dec-${stampNow()}.zip`;
4072
- await writeBinary(tmp, plain);
4085
+ await decryptFile(src, tmp, sidecar, password);
4073
4086
  return { zipPath: tmp, encrypted: true, cleanup: () => rm(tmp, { force: true }) };
4074
4087
  }
4075
4088
  return { zipPath: src, encrypted: false, cleanup: null };
@@ -4165,7 +4178,7 @@ export function apply(ctx, pluginConfig) {
4165
4178
  const remap = await remapScan(zipPath, manifest, home);
4166
4179
  const compatibility = compatScore(manifest, home);
4167
4180
  const totalFiles = sections.reduce((s, x) => s + x.files, 0);
4168
- const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,可在界面逐项选择覆盖)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
4181
+ const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,不改动已有文件)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
4169
4182
  return { ok: true, encrypted, manifest, sections, totalFiles, totalBytes: manifest.totalBytes ?? 0, conflicts, remap, compatibility, summary };
4170
4183
  } finally {
4171
4184
  if (cleanup) await cleanup().catch(() => {});
@@ -4227,14 +4240,7 @@ export function apply(ctx, pluginConfig) {
4227
4240
  await prunePrefixedSnapshots(IMPORT_SNAPSHOT_PREFIX, 1, snap.name);
4228
4241
  } catch { /* 快照失败不阻断导入 */ }
4229
4242
 
4230
- // 测试专用故障注入(test-only):DSH_BACKUP_IMPORT_FAIL=1 时,在「自动快照完成
4231
- // 之后、真正写入之前」抛一次错,模拟导入中途失败——零依赖 smoke 据此断言失败
4232
- // 路径与快照安全网(~/.dsh 未被改动)。生产默认绝不触发:环境变量未显式命中
4233
- // '1' 时此分支永不进入。
4234
- if (process.env.DSH_BACKUP_IMPORT_FAIL === '1') {
4235
- throw new Error('test-only import fault injection (DSH_BACKUP_IMPORT_FAIL=1)');
4236
- }
4237
-
4243
+
4238
4244
  // 解压到暂存目录(extractZip 内 Zip Slip/符号链接/zip bomb/CRC 全套防线),再拷入 ~/.dsh
4239
4245
  staging = `${root}/.import-stage-${stampNow()}`;
4240
4246
  await extractZip(zipPath, staging, { names: new Set(willWrite.map((e) => e.name)), signal });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoyuyu6420/dsh-backup",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "一条命令备份、恢复与迁移 DeepSeek Harness(dsh)的全部数据(~/.dsh:会话、设置、插件、MCP、技能):定时自动备份、升级前快照、换机迁移、会话日志体检修复、宿主起不来也能用的进程外救援;凭据默认脱敏只存本机。 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data — sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.24.0",
package/rescue/rescue.mjs CHANGED
@@ -830,6 +830,10 @@ async function sessionsRepair(root, dshHome, argv) {
830
830
  }
831
831
  }
832
832
  if (!maps.length && keep === undefined) throw new Error('sessions repair 至少需要 --map old=new 或 --keep <id>');
833
+ // --map 会**改写会话 header 里的 cwd**(动用户自己的日志),必须显式确认。
834
+ if (fix && maps.length && !argv.includes('--yes')) {
835
+ throw new Error('sessions repair --fix --map 会原地改写会话 header 的 cwd(动用户日志)——确认请加 --yes;不加 --fix 时仅预览');
836
+ }
833
837
 
834
838
  const files = [];
835
839
  await collectSessionLogs(`${dshHome}/sessions`, '', 0, files);