@xiaoyuyu6420/dsh-backup 0.13.4 → 0.14.1
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.en.md +21 -5
- package/README.md +21 -5
- package/lib/client.js +136 -33
- package/lib/encrypt.js +135 -0
- package/lib/gfs.js +52 -0
- package/lib/index.js +3141 -172
- package/lib/oplock.js +123 -0
- package/lib/sync-channels.js +161 -0
- package/lib/zip.js +401 -0
- package/package.json +1 -1
- package/rescue/rescue.mjs +181 -1
package/lib/zip.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-backup — 零依赖 ZIP 读写(导出/导入的宿主半边)。
|
|
3
|
+
*
|
|
4
|
+
* 设计取舍:
|
|
5
|
+
* - 导出(buildZip)用 STORE(method 0,不压缩)流式写出:~/.dsh 的大头是
|
|
6
|
+
* zstd 会话日志(已压缩),settings/cordis 等文本体积很小,压缩收益有限;
|
|
7
|
+
* STORE 免去 deflate 流式管线的复杂度,且写出的 ZIP 每个 unzip 工具都能读。
|
|
8
|
+
* 压缩可以后续再加(写入侧只需换 method + 接一段 deflateRaw)。
|
|
9
|
+
* - 导入(listZip / extractZip)**同时支持 STORE 与 DEFLATE**:导入的 ZIP 是
|
|
10
|
+
* 不可信输入,可能是任何工具产出的压缩包,读侧必须能解 DEFLATE。
|
|
11
|
+
* - 安全(硬性):Zip Slip——条目名禁止绝对路径 / 盘符 / `..` 段 / 反斜杠归一化
|
|
12
|
+
* 后再判,落盘路径 resolve 后必须仍在目标目录内;符号链接/设备等特殊条目一律
|
|
13
|
+
* 拒绝;zip bomb——解压前用中央目录声明的未压缩体积与压缩比双重设限;CRC32
|
|
14
|
+
* 解压后校验,不一致拒收。
|
|
15
|
+
*
|
|
16
|
+
* 所有常量/结构均为本地 ZIP 格式实现(PKZIP AppNote),无第三方依赖。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
20
|
+
import { open, mkdir, stat as fsStat } from 'node:fs/promises';
|
|
21
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
22
|
+
import { once } from 'node:events';
|
|
23
|
+
import zlib from 'node:zlib';
|
|
24
|
+
|
|
25
|
+
/** 条目数量上限:防止中央目录声明天文数字把内存/磁盘打爆。 */
|
|
26
|
+
export const ZIP_MAX_ENTRIES = 100_000;
|
|
27
|
+
/** 单个条目未压缩体积上限(解压前拒绝,防 zip bomb)。 */
|
|
28
|
+
export const ZIP_MAX_ENTRY_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
|
|
29
|
+
/** 压缩比上限:未压缩/压缩 > 1000 视为 zip bomb。 */
|
|
30
|
+
export const ZIP_MAX_RATIO = 1000;
|
|
31
|
+
/** 中央目录读取上限(防御性:正规 ZIP 的中央目录远小于此)。 */
|
|
32
|
+
export const ZIP_MAX_CENTRAL_BYTES = 256 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
const SIG_LOCAL = 0x04034b50;
|
|
35
|
+
const SIG_CENTRAL = 0x02014b50;
|
|
36
|
+
const SIG_EOCD = 0x06054b50;
|
|
37
|
+
const SIG_DESCRIPTOR = 0x08074b50;
|
|
38
|
+
|
|
39
|
+
const METHOD_STORE = 0;
|
|
40
|
+
const METHOD_DEFLATE = 8;
|
|
41
|
+
/** 一般用途位标志:bit3=数据描述符、bit11=UTF-8 文件名。 */
|
|
42
|
+
const FLAG_DESCRIPTOR = 0x0008;
|
|
43
|
+
const FLAG_UTF8 = 0x0800;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* CRC-32(IEEE 802.3 多项式 0xEDB88320,查表)。ZIP 的 CRC 字段用同一算法;
|
|
47
|
+
* node:zlib 不暴露 CRC32,这里自己实现一个增量版本。
|
|
48
|
+
*/
|
|
49
|
+
class Crc32 {
|
|
50
|
+
static table = (() => {
|
|
51
|
+
const t = new Uint32Array(256);
|
|
52
|
+
for (let n = 0; n < 256; n++) {
|
|
53
|
+
let c = n;
|
|
54
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
55
|
+
t[n] = c >>> 0;
|
|
56
|
+
}
|
|
57
|
+
return t;
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
constructor() { this.crc = 0xffffffff; }
|
|
61
|
+
|
|
62
|
+
update(chunk) {
|
|
63
|
+
let c = this.crc;
|
|
64
|
+
const t = Crc32.table;
|
|
65
|
+
for (let i = 0; i < chunk.length; i++) c = t[(c ^ chunk[i]) & 0xff] ^ (c >>> 8);
|
|
66
|
+
this.crc = c >>> 0;
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get value() { return (this.crc ^ 0xffffffff) >>> 0; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function u16(buf, off) { return buf.readUInt16LE(off); }
|
|
74
|
+
function u32(buf, off) { return buf.readUInt32LE(off); }
|
|
75
|
+
|
|
76
|
+
/** 把 JS Date 编码成 ZIP 的 DOS 时间/日期(各 2 字节,小端)。 */
|
|
77
|
+
function dosDateTime(date) {
|
|
78
|
+
const d = date || new Date();
|
|
79
|
+
const time = ((d.getHours() & 0x1f) << 11) | ((d.getMinutes() & 0x3f) << 5) | ((Math.floor(d.getSeconds() / 2) & 0x1f));
|
|
80
|
+
const day = ((d.getFullYear() - 1980) & 0x7f) << 9 | ((d.getMonth() + 1) & 0x0f) << 5 | (d.getDate() & 0x1f);
|
|
81
|
+
return { time, date: day };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 写一个 Buffer 到 write stream,处理背压(等 drain),并返回逻辑字节数。 */
|
|
85
|
+
async function writeBuf(ws, buf) {
|
|
86
|
+
if (!buf.length) return 0;
|
|
87
|
+
if (!ws.write(buf)) await once(ws, 'drain');
|
|
88
|
+
return buf.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 校验 ZIP 条目名(Zip Slip 第一道防线)。返回归一化后的 POSIX 相对名;非法抛错。
|
|
93
|
+
* 拒绝:空名、绝对路径、Windows 盘符、任何 `..` 段、反斜杠(归一化后再查一次)、
|
|
94
|
+
* 控制字符。
|
|
95
|
+
*/
|
|
96
|
+
export function sanitizeEntryName(raw) {
|
|
97
|
+
if (typeof raw !== 'string' || !raw) throw new Error(`ZIP 条目名为空`);
|
|
98
|
+
let name = raw.replace(/\\/g, '/');
|
|
99
|
+
if (name.startsWith('/') || /^[A-Za-z]:/.test(name)) throw new Error(`ZIP 条目越界(绝对路径): ${raw}`);
|
|
100
|
+
const segs = name.split('/');
|
|
101
|
+
if (segs.includes('..') || segs.includes('')) throw new Error(`ZIP 条目越界(含 .. 或空段): ${raw}`);
|
|
102
|
+
// 控制字符/换行:文件名里出现即视为恶意。
|
|
103
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) throw new Error(`ZIP 条目名含控制字符: ${raw}`);
|
|
104
|
+
return name;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 把内存 Buffer 包成小端字段辅助器,避免手写 read/write 偏移出错。
|
|
109
|
+
*/
|
|
110
|
+
function putLocalHeader({ nameBytes, method, dosTime, dosDate, crc, csize, usize }) {
|
|
111
|
+
const n = nameBytes.length;
|
|
112
|
+
const buf = Buffer.alloc(30 + n);
|
|
113
|
+
buf.writeUInt32LE(SIG_LOCAL, 0);
|
|
114
|
+
buf.writeUInt16LE(20, 4); // version needed
|
|
115
|
+
buf.writeUInt16LE(FLAG_DESCRIPTOR | FLAG_UTF8, 6);
|
|
116
|
+
buf.writeUInt16LE(method, 8);
|
|
117
|
+
buf.writeUInt16LE(dosTime, 10);
|
|
118
|
+
buf.writeUInt16LE(dosDate, 12);
|
|
119
|
+
buf.writeUInt32LE(crc, 14);
|
|
120
|
+
buf.writeUInt32LE(csize, 18);
|
|
121
|
+
buf.writeUInt32LE(usize, 22);
|
|
122
|
+
buf.writeUInt16LE(n, 26);
|
|
123
|
+
buf.writeUInt16LE(0, 28); // extra len
|
|
124
|
+
nameBytes.copy(buf, 30);
|
|
125
|
+
return buf;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function putDescriptor(crc, csize, usize) {
|
|
129
|
+
const buf = Buffer.alloc(16);
|
|
130
|
+
buf.writeUInt32LE(SIG_DESCRIPTOR, 0);
|
|
131
|
+
buf.writeUInt32LE(crc, 4);
|
|
132
|
+
buf.writeUInt32LE(csize, 8);
|
|
133
|
+
buf.writeUInt32LE(usize, 12);
|
|
134
|
+
return buf;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function putCentral(entry) {
|
|
138
|
+
const n = entry.nameBytes.length;
|
|
139
|
+
const buf = Buffer.alloc(46 + n);
|
|
140
|
+
buf.writeUInt32LE(SIG_CENTRAL, 0);
|
|
141
|
+
buf.writeUInt16LE(20, 4); // version made by
|
|
142
|
+
buf.writeUInt16LE(20, 6); // version needed
|
|
143
|
+
buf.writeUInt16LE(FLAG_DESCRIPTOR | FLAG_UTF8, 8);
|
|
144
|
+
buf.writeUInt16LE(entry.method, 10);
|
|
145
|
+
buf.writeUInt16LE(entry.dosTime, 12);
|
|
146
|
+
buf.writeUInt16LE(entry.dosDate, 14);
|
|
147
|
+
buf.writeUInt32LE(entry.crc, 16);
|
|
148
|
+
buf.writeUInt32LE(entry.csize, 20);
|
|
149
|
+
buf.writeUInt32LE(entry.usize, 24);
|
|
150
|
+
buf.writeUInt16LE(n, 28);
|
|
151
|
+
buf.writeUInt16LE(0, 30); // extra
|
|
152
|
+
buf.writeUInt16LE(0, 32); // comment
|
|
153
|
+
buf.writeUInt16LE(0, 34); // disk start
|
|
154
|
+
buf.writeUInt16LE(0, 36); // internal attrs
|
|
155
|
+
buf.writeUInt32LE(0, 38); // external attrs(普通文件)
|
|
156
|
+
buf.writeUInt32LE(entry.offset, 42);
|
|
157
|
+
nameBytesFor(entry.name).copy(buf, 46);
|
|
158
|
+
return buf;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function nameBytesFor(name) { return Buffer.from(name, 'utf8'); }
|
|
162
|
+
|
|
163
|
+
function putEocd(total, cdSize, cdOffset) {
|
|
164
|
+
const buf = Buffer.alloc(22);
|
|
165
|
+
buf.writeUInt32LE(SIG_EOCD, 0);
|
|
166
|
+
buf.writeUInt16LE(0, 4);
|
|
167
|
+
buf.writeUInt16LE(0, 6);
|
|
168
|
+
buf.writeUInt16LE(total, 8);
|
|
169
|
+
buf.writeUInt16LE(total, 10);
|
|
170
|
+
buf.writeUInt32LE(cdSize, 12);
|
|
171
|
+
buf.writeUInt32LE(cdOffset, 16);
|
|
172
|
+
buf.writeUInt16LE(0, 20);
|
|
173
|
+
return buf;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 把磁盘上的若干文件打包成单文件 ZIP(STORE)。entries: [{ name, sourcePath }],
|
|
178
|
+
* name 为 ZIP 内 POSIX 相对名,sourcePath 为磁盘上的绝对路径(必须是普通文件,
|
|
179
|
+
* 符号链接/目录会被拒绝——导出不跟随符号链接,防止把备份目录外的内容卷进来)。
|
|
180
|
+
*
|
|
181
|
+
* @param {string} outputPath 输出 ZIP 的绝对路径(父目录需已存在)
|
|
182
|
+
* @param {Array<{name:string, sourcePath?:string, data?:Buffer}>} entries
|
|
183
|
+
* 每个条目二选一:`sourcePath`(磁盘文件,流式读入)或 `data`(内存 Buffer,
|
|
184
|
+
* 适合 manifest 这类小文件)。两者都给时优先 `data`。
|
|
185
|
+
* @param {{ signal?: AbortSignal, onProgress?: (done:number, total:number)=>void }} [opts]
|
|
186
|
+
* @returns {Promise<{ path: string, files: number, bytes: number }>}
|
|
187
|
+
*/
|
|
188
|
+
export async function buildZip(outputPath, entries, opts = {}) {
|
|
189
|
+
const list = entries.map((e) => ({ name: sanitizeEntryName(e.name), sourcePath: e.sourcePath, data: e.data }));
|
|
190
|
+
if (list.length > ZIP_MAX_ENTRIES) throw new Error(`ZIP 条目过多(${list.length} > ${ZIP_MAX_ENTRIES})`);
|
|
191
|
+
const ws = createWriteStream(outputPath);
|
|
192
|
+
const central = [];
|
|
193
|
+
let offset = 0;
|
|
194
|
+
const st = (d) => d ? new Date(d) : new Date();
|
|
195
|
+
try {
|
|
196
|
+
for (const entry of list) {
|
|
197
|
+
if (opts.signal?.aborted) throw new Error('操作已取消');
|
|
198
|
+
const isBuffer = Buffer.isBuffer(entry.data);
|
|
199
|
+
const info = isBuffer ? null : await fsStat(entry.sourcePath);
|
|
200
|
+
if (!isBuffer && !info.isFile()) throw new Error(`导出源不是普通文件(拒绝符号链接/目录): ${entry.sourcePath}`);
|
|
201
|
+
const nameBytes = nameBytesFor(entry.name);
|
|
202
|
+
const { time, date } = dosDateTime(isBuffer ? null : st(info.mtime));
|
|
203
|
+
const crc = new Crc32();
|
|
204
|
+
let usize = 0;
|
|
205
|
+
const entryOffset = offset;
|
|
206
|
+
// 先写本地头(crc/size 未知 → 用数据描述符补全,见 FLAG_DESCRIPTOR)
|
|
207
|
+
offset += await writeBuf(ws, putLocalHeader({ nameBytes, method: METHOD_STORE, dosTime: time, dosDate: date, crc: 0, csize: 0, usize: 0 }));
|
|
208
|
+
if (isBuffer) {
|
|
209
|
+
crc.update(entry.data);
|
|
210
|
+
usize = entry.data.length;
|
|
211
|
+
offset += await writeBuf(ws, entry.data);
|
|
212
|
+
} else {
|
|
213
|
+
const rs = createReadStream(entry.sourcePath);
|
|
214
|
+
for await (const chunk of rs) {
|
|
215
|
+
if (opts.signal?.aborted) { rs.destroy(); throw new Error('操作已取消'); }
|
|
216
|
+
crc.update(chunk);
|
|
217
|
+
usize += chunk.length;
|
|
218
|
+
offset += await writeBuf(ws, chunk);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
offset += await writeBuf(ws, putDescriptor(crc.value, usize, usize));
|
|
222
|
+
central.push({ name: entry.name, nameBytes, method: METHOD_STORE, dosTime: time, dosDate: date, crc: crc.value, csize: usize, usize, offset: entryOffset });
|
|
223
|
+
opts.onProgress?.(central.length, list.length);
|
|
224
|
+
}
|
|
225
|
+
const cdStart = offset;
|
|
226
|
+
let cdSize = 0;
|
|
227
|
+
for (const c of central) { const b = putCentral(c); offset += await writeBuf(ws, b); cdSize += b.length; }
|
|
228
|
+
offset += await writeBuf(ws, putEocd(central.length, cdSize, cdStart));
|
|
229
|
+
} finally {
|
|
230
|
+
ws.end();
|
|
231
|
+
}
|
|
232
|
+
await once(ws, 'close');
|
|
233
|
+
return { path: outputPath, files: central.length, bytes: offset };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 从已打开的文件句柄按字节范围读取(位置式读,不移动共享游标)。 */
|
|
237
|
+
async function readRange(fh, buffer, start, length, position) {
|
|
238
|
+
const buf = buffer && buffer.length >= length ? buffer : Buffer.alloc(length);
|
|
239
|
+
const { bytesRead } = await fh.read(buf, 0, length, position);
|
|
240
|
+
if (bytesRead !== length) throw new Error('ZIP 读取不完整(文件被截断)');
|
|
241
|
+
return buf;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 解析 ZIP 中央目录,返回可信条目清单(名称/方法/CRC/尺寸/本地头偏移等)。
|
|
246
|
+
* 中央目录是 ZIP 的权威索引;读它而不是顺序扫本地头,能避开「本地头被篡改、
|
|
247
|
+
* 中央目录不一致」的经典攻击面(本地头在 extractZip 里会再次逐项核对)。
|
|
248
|
+
*
|
|
249
|
+
* @returns {Promise<Array<{name:string, method:number, flags:number, crc:number, csize:number, usize:number, offset:number, externalAttrs:number, isDirectory:boolean}>>}
|
|
250
|
+
*/
|
|
251
|
+
export async function listZip(zipPath) {
|
|
252
|
+
const fh = await open(zipPath, 'r');
|
|
253
|
+
try {
|
|
254
|
+
const size = (await fh.stat()).size;
|
|
255
|
+
if (size < 22) throw new Error('ZIP 文件太小,不是有效压缩包');
|
|
256
|
+
// EOCD 最大 22 + 65535 注释;从末尾往回找签名
|
|
257
|
+
const tailLen = Math.min(size, 22 + 0xffff);
|
|
258
|
+
const tail = await readRange(fh, null, 0, tailLen, size - tailLen);
|
|
259
|
+
let eocdPos = -1;
|
|
260
|
+
for (let i = tail.length - 22; i >= 0; i--) {
|
|
261
|
+
if (tail.readUInt32LE(i) === SIG_EOCD) { eocdPos = size - tailLen + i; break; }
|
|
262
|
+
}
|
|
263
|
+
if (eocdPos < 0) throw new Error('ZIP 缺少结束目录(EOCD)——不是有效压缩包');
|
|
264
|
+
const eocdBuf = await readRange(fh, null, 0, 22, eocdPos);
|
|
265
|
+
const total = u16(eocdBuf, 10);
|
|
266
|
+
const cdSize = u32(eocdBuf, 12);
|
|
267
|
+
const cdOffset = u32(eocdBuf, 16);
|
|
268
|
+
if (total > ZIP_MAX_ENTRIES) throw new Error(`ZIP 条目数异常(${total} > ${ZIP_MAX_ENTRIES}),拒绝解析`);
|
|
269
|
+
if (cdSize > ZIP_MAX_CENTRAL_BYTES || cdOffset + cdSize > size) throw new Error('ZIP 中央目录越界,拒绝解析');
|
|
270
|
+
const cd = await readRange(fh, null, 0, cdSize, cdOffset);
|
|
271
|
+
const entries = [];
|
|
272
|
+
let p = 0;
|
|
273
|
+
for (let i = 0; i < total; i++) {
|
|
274
|
+
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG_CENTRAL) throw new Error('ZIP 中央目录损坏');
|
|
275
|
+
const method = u16(cd, p + 10);
|
|
276
|
+
const flags = u16(cd, p + 8);
|
|
277
|
+
const crc = u32(cd, p + 16);
|
|
278
|
+
const csize = u32(cd, p + 20);
|
|
279
|
+
const usize = u32(cd, p + 24);
|
|
280
|
+
const nameLen = u16(cd, p + 28);
|
|
281
|
+
const extraLen = u16(cd, p + 30);
|
|
282
|
+
const commentLen = u16(cd, p + 32);
|
|
283
|
+
const externalAttrs = u32(cd, p + 38);
|
|
284
|
+
const offset = u32(cd, p + 42);
|
|
285
|
+
if (p + 46 + nameLen + extraLen + commentLen > cd.length) throw new Error('ZIP 中央目录条目越界');
|
|
286
|
+
const name = sanitizeEntryName(cd.subarray(p + 46, p + 46 + nameLen).toString('utf8'));
|
|
287
|
+
entries.push({ name, method, flags, crc, csize, usize, offset, externalAttrs, isDirectory: name.endsWith('/') });
|
|
288
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
289
|
+
}
|
|
290
|
+
return entries;
|
|
291
|
+
} finally {
|
|
292
|
+
await fh.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** unix 外部属性高 16 位是模式位;0xA000 即 S_IFLNK(符号链接)。 */
|
|
297
|
+
function isSymlinkAttrs(externalAttrs) {
|
|
298
|
+
return ((externalAttrs >>> 16) & 0xf000) === 0xa000;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 安全解压 ZIP 的指定条目到 destDir(Zip Slip 全套防线)。
|
|
303
|
+
* 只按中央目录声明的名字/偏移取数;本地头会被逐项核对;符号链接/特殊文件拒绝。
|
|
304
|
+
*
|
|
305
|
+
* @param {string} zipPath ZIP 路径
|
|
306
|
+
* @param {string} destDir 目标目录(绝对路径)
|
|
307
|
+
* @param {{ names?: string[]|Set<string>, signal?: AbortSignal, onProgress?: (done:number,total:number)=>void }} [opts]
|
|
308
|
+
* names 省略时解压全部文件条目。
|
|
309
|
+
* @returns {Promise<{ extracted: string[], skippedDirs: number }>} 相对 destDir 的落盘文件路径
|
|
310
|
+
*/
|
|
311
|
+
export async function extractZip(zipPath, destDir, opts = {}) {
|
|
312
|
+
const entries = await listZip(zipPath);
|
|
313
|
+
const wanted = opts.names ? new Set(opts.names) : null;
|
|
314
|
+
const files = entries.filter((e) => !e.isDirectory);
|
|
315
|
+
const selected = wanted ? files.filter((e) => wanted.has(e.name)) : files;
|
|
316
|
+
// zip bomb:先按中央目录声明的未压缩体积合计设限
|
|
317
|
+
let totalUsize = 0;
|
|
318
|
+
for (const e of selected) {
|
|
319
|
+
totalUsize += e.usize;
|
|
320
|
+
if (e.usize > ZIP_MAX_ENTRY_BYTES) throw new Error(`ZIP 条目过大(${e.name}: ${e.usize} 字节),拒绝解压`);
|
|
321
|
+
}
|
|
322
|
+
const destRoot = resolve(destDir);
|
|
323
|
+
const fh = await open(zipPath, 'r');
|
|
324
|
+
const extracted = [];
|
|
325
|
+
try {
|
|
326
|
+
for (const e of selected) {
|
|
327
|
+
if (opts.signal?.aborted) throw new Error('操作已取消');
|
|
328
|
+
if (isSymlinkAttrs(e.externalAttrs)) throw new Error(`ZIP 含符号链接条目,拒绝解压: ${e.name}`);
|
|
329
|
+
if (e.method !== METHOD_STORE && e.method !== METHOD_DEFLATE) {
|
|
330
|
+
throw new Error(`ZIP 条目使用不支持的压缩方法(method=${e.method}): ${e.name}`);
|
|
331
|
+
}
|
|
332
|
+
if (e.csize === 0 && e.usize > 0) throw new Error(`ZIP 条目声明异常(空数据却有 ${e.usize} 字节): ${e.name}`);
|
|
333
|
+
if (e.usize > 0 && e.csize > 0 && e.usize / e.csize > ZIP_MAX_RATIO) throw new Error(`ZIP 条目疑似 zip bomb(压缩比异常): ${e.name}`);
|
|
334
|
+
// 读本地头核对(签名 + 名字长度与中央目录一致)
|
|
335
|
+
const lh = await readRange(fh, null, 0, 30, e.offset);
|
|
336
|
+
if (lh.readUInt32LE(0) !== SIG_LOCAL) throw new Error(`ZIP 本地头损坏: ${e.name}`);
|
|
337
|
+
const localNameLen = u16(lh, 26);
|
|
338
|
+
const localExtraLen = u16(lh, 28);
|
|
339
|
+
const dataOffset = e.offset + 30 + localNameLen + localExtraLen;
|
|
340
|
+
const compressed = await readRange(fh, null, 0, e.csize, dataOffset);
|
|
341
|
+
let raw;
|
|
342
|
+
if (e.method === METHOD_DEFLATE) {
|
|
343
|
+
raw = zlib.inflateRawSync(compressed);
|
|
344
|
+
} else {
|
|
345
|
+
raw = compressed;
|
|
346
|
+
}
|
|
347
|
+
if (raw.length !== e.usize) throw new Error(`ZIP 条目解压后尺寸不符: ${e.name}`);
|
|
348
|
+
const crc = new Crc32(); crc.update(raw);
|
|
349
|
+
if (crc.value !== e.crc) throw new Error(`ZIP 条目 CRC 校验失败(已损坏或被篡改): ${e.name}`);
|
|
350
|
+
// 落盘前最后一道 Zip Slip:resolve 后必须仍在 destRoot 内
|
|
351
|
+
const target = resolve(destRoot, e.name);
|
|
352
|
+
if (target !== destRoot && !target.startsWith(destRoot + sep)) throw new Error(`ZIP 条目越界(Zip Slip 拦截): ${e.name}`);
|
|
353
|
+
await mkdir(dirname(target), { recursive: true });
|
|
354
|
+
const ws = createWriteStream(target);
|
|
355
|
+
if (!ws.write(raw)) await once(ws, 'drain');
|
|
356
|
+
ws.end();
|
|
357
|
+
await once(ws, 'close');
|
|
358
|
+
extracted.push(e.name);
|
|
359
|
+
opts.onProgress?.(extracted.length, selected.length);
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
await fh.close();
|
|
363
|
+
}
|
|
364
|
+
return { extracted, skippedDirs: entries.filter((e) => e.isDirectory).length };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 判断某条目是否是目录条目(中央目录视角)。 */
|
|
368
|
+
export function entryIsDirectory(entry) { return entry.isDirectory; }
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 读取 ZIP 中单个条目的解压后内容(返回 Buffer)。仅用于小文件(manifest、
|
|
372
|
+
* settings 等);大数据请走 extractZip 流式落盘。校验与 extractZip 同款:
|
|
373
|
+
* 本地头核对 + 尺寸/CRC 校验 + 符号链接/未知方法拒绝 + zip bomb 比例限。
|
|
374
|
+
*
|
|
375
|
+
* @param {string} zipPath ZIP 路径
|
|
376
|
+
* @param {string} name 条目名(精确匹配)
|
|
377
|
+
* @returns {Promise<Buffer>}
|
|
378
|
+
*/
|
|
379
|
+
export async function readZipEntry(zipPath, name) {
|
|
380
|
+
const entries = await listZip(zipPath);
|
|
381
|
+
const e = entries.find((x) => x.name === name && !x.isDirectory);
|
|
382
|
+
if (!e) throw new Error(`ZIP 里没有条目: ${name}`);
|
|
383
|
+
if (isSymlinkAttrs(e.externalAttrs)) throw new Error(`ZIP 含符号链接条目,拒绝读取: ${e.name}`);
|
|
384
|
+
if (e.method !== METHOD_STORE && e.method !== METHOD_DEFLATE) throw new Error(`ZIP 条目使用不支持的压缩方法(method=${e.method}): ${e.name}`);
|
|
385
|
+
if (e.usize > 64 * 1024 * 1024) throw new Error(`ZIP 条目过大,拒绝整读(${e.name}: ${e.usize} 字节)`);
|
|
386
|
+
if (e.usize > 0 && e.csize > 0 && e.usize / e.csize > ZIP_MAX_RATIO) throw new Error(`ZIP 条目疑似 zip bomb(压缩比异常): ${e.name}`);
|
|
387
|
+
const fh = await open(zipPath, 'r');
|
|
388
|
+
try {
|
|
389
|
+
const lh = await readRange(fh, null, 0, 30, e.offset);
|
|
390
|
+
if (lh.readUInt32LE(0) !== SIG_LOCAL) throw new Error(`ZIP 本地头损坏: ${e.name}`);
|
|
391
|
+
const dataOffset = e.offset + 30 + u16(lh, 26) + u16(lh, 28);
|
|
392
|
+
const compressed = await readRange(fh, null, 0, e.csize, dataOffset);
|
|
393
|
+
const raw = e.method === METHOD_DEFLATE ? zlib.inflateRawSync(compressed) : compressed;
|
|
394
|
+
if (raw.length !== e.usize) throw new Error(`ZIP 条目解压后尺寸不符: ${e.name}`);
|
|
395
|
+
const crc = new Crc32(); crc.update(raw);
|
|
396
|
+
if (crc.value !== e.crc) throw new Error(`ZIP 条目 CRC 校验失败(已损坏或被篡改): ${e.name}`);
|
|
397
|
+
return raw;
|
|
398
|
+
} finally {
|
|
399
|
+
await fh.close();
|
|
400
|
+
}
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* 老归档(无边车 v0.6.x)静默降级,与插件一致。
|
|
25
25
|
*/
|
|
26
26
|
import { spawn, spawnSync } from 'node:child_process';
|
|
27
|
-
import { createHash } from 'node:crypto';
|
|
27
|
+
import { createHash, randomInt } from 'node:crypto';
|
|
28
28
|
import { createReadStream } from 'node:fs';
|
|
29
29
|
import fs from 'node:fs/promises';
|
|
30
30
|
import http from 'node:http';
|
|
@@ -743,6 +743,176 @@ function serve(root, port, autoOpen) {
|
|
|
743
743
|
return server;
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
+
// ---------- 离线 CLI 补全(P4:reinstall / sessions repair / recover-stale-lock) ----------
|
|
747
|
+
|
|
748
|
+
const LOCK_FILE = '.oplock.json';
|
|
749
|
+
const REINSTALL_CODE_FILE = '.reinstall-code';
|
|
750
|
+
|
|
751
|
+
/** 与 lib/oplock.js 同语义:仅 ESRCH 判死,EPERM/存活/僵尸一律判活(绝不误踢)。 */
|
|
752
|
+
function isPidAlive(pid) {
|
|
753
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
754
|
+
try {
|
|
755
|
+
process.kill(pid, 0);
|
|
756
|
+
return true;
|
|
757
|
+
} catch (err) {
|
|
758
|
+
return err && err.code === 'EPERM';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** 回收残留死锁(owner 证明已死才回收)。 */
|
|
763
|
+
async function recoverStaleLock(root) {
|
|
764
|
+
const lockPath = `${root}/${LOCK_FILE}`;
|
|
765
|
+
let lock;
|
|
766
|
+
try {
|
|
767
|
+
lock = JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
|
768
|
+
} catch (err) {
|
|
769
|
+
if (err && err.code === 'ENOENT') { console.log('没有操作锁。'); return; }
|
|
770
|
+
throw new Error(`操作锁读取失败: ${String(err && err.message ? err.message : err)}`);
|
|
771
|
+
}
|
|
772
|
+
const pid = Number(lock.pid);
|
|
773
|
+
if (isPidAlive(pid)) {
|
|
774
|
+
console.log(`❌ 锁持有进程仍在运行(pid=${pid}, operation=${lock.operation})——未回收,避免误踢活锁。`);
|
|
775
|
+
process.exitCode = 1;
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
await fs.rm(lockPath, { force: true });
|
|
779
|
+
console.log(`✅ 已回收死锁(原 pid=${pid}, operation=${lock.operation})。`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** 重装全局 CLI:卸载 + 重装,6 位确认码门禁(先打印,--code 校验后才执行)。 */
|
|
783
|
+
async function reinstall(root, argv) {
|
|
784
|
+
const pkgIdx = argv.indexOf('--package');
|
|
785
|
+
const pkg = pkgIdx >= 0 && argv[pkgIdx + 1] ? argv[pkgIdx + 1] : '@deepseek-ai/dsh';
|
|
786
|
+
const codeIdx = argv.indexOf('--code');
|
|
787
|
+
const codeArg = codeIdx >= 0 ? argv[codeIdx + 1] : undefined;
|
|
788
|
+
const codeFile = `${root}/${REINSTALL_CODE_FILE}`;
|
|
789
|
+
const npmBin = IS_WIN ? 'npm.cmd' : 'npm';
|
|
790
|
+
if (!codeArg) {
|
|
791
|
+
const code = String(randomInt(0, 1000000)).padStart(6, '0');
|
|
792
|
+
await fs.writeFile(codeFile, `${code}\n`, 'utf8');
|
|
793
|
+
console.log(`⚠️ 即将重装全局 CLI(${pkg}):`);
|
|
794
|
+
console.log(` 1) ${npmBin} uninstall -g ${pkg}`);
|
|
795
|
+
console.log(` 2) ${npmBin} install -g ${pkg}`);
|
|
796
|
+
console.log(` 确认码: ${code}`);
|
|
797
|
+
console.log(` 确认执行: node rescue.mjs reinstall --package ${pkg} --code ${code}`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
let expected = '';
|
|
801
|
+
try { expected = (await fs.readFile(codeFile, 'utf8')).trim(); } catch { expected = ''; }
|
|
802
|
+
if (!expected || codeArg !== expected) {
|
|
803
|
+
throw new Error('确认码不匹配或已过期——请重新运行 reinstall 获取新确认码');
|
|
804
|
+
}
|
|
805
|
+
await fs.rm(codeFile, { force: true });
|
|
806
|
+
run([npmBin, 'uninstall', '-g', pkg]);
|
|
807
|
+
run([npmBin, 'install', '-g', pkg]);
|
|
808
|
+
console.log(`✅ 已重装全局 CLI: ${pkg}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* 会话日志首帧 cwd 重定位(P4):默认 dry-run 只出计划;--fix 才动。
|
|
813
|
+
* - --map old=new(可多个):对 header JSON 的 `cwd` 字段做前缀重写
|
|
814
|
+
* - --keep <id>:去重选择器——只处理 header.id === <id> 的会话,重复 id 跳过
|
|
815
|
+
* - 原地原子写(临时文件 + rename,目标即原文件,不存在「目标已存在」的覆盖)
|
|
816
|
+
* - 失败回滚:写前留档 *.repair-<时间戳>,失败从留档还原
|
|
817
|
+
* zstd 文件只重写首帧(保持「首帧恰好一行 header」容器契约),其余帧原样保留。
|
|
818
|
+
*/
|
|
819
|
+
async function sessionsRepair(root, dshHome, argv) {
|
|
820
|
+
const fix = argv.includes('--fix');
|
|
821
|
+
const keepIdx = argv.indexOf('--keep');
|
|
822
|
+
const keep = keepIdx >= 0 && argv[keepIdx + 1] ? argv[keepIdx + 1] : undefined;
|
|
823
|
+
const maps = [];
|
|
824
|
+
for (let i = 0; i < argv.length; i++) {
|
|
825
|
+
if (argv[i] === '--map') {
|
|
826
|
+
const spec = argv[i + 1] || '';
|
|
827
|
+
const eq = spec.indexOf('=');
|
|
828
|
+
if (eq < 0) throw new Error(`--map 需为 old=new 形式,收到 ${spec || '(缺值)'}`);
|
|
829
|
+
maps.push({ from: spec.slice(0, eq), to: spec.slice(eq + 1) });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (!maps.length && keep === undefined) throw new Error('sessions repair 至少需要 --map old=new 或 --keep <id>');
|
|
833
|
+
|
|
834
|
+
const files = [];
|
|
835
|
+
await collectSessionLogs(`${dshHome}/sessions`, '', 0, files);
|
|
836
|
+
const plans = [];
|
|
837
|
+
const seenIds = new Set();
|
|
838
|
+
for (const f of files) {
|
|
839
|
+
let header = null;
|
|
840
|
+
let lines = null;
|
|
841
|
+
try {
|
|
842
|
+
if (f.abs.endsWith('.zstd')) {
|
|
843
|
+
({ lines } = decodeZstdLog(await fs.readFile(f.abs)));
|
|
844
|
+
} else {
|
|
845
|
+
lines = (await fs.readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
846
|
+
}
|
|
847
|
+
if (lines && lines.length) header = JSON.parse(lines[0]);
|
|
848
|
+
} catch { header = null; }
|
|
849
|
+
if (!header || typeof header !== 'object') { plans.push({ rel: f.rel, id: null, status: 'skip', reason: 'header 不可解析' }); continue; }
|
|
850
|
+
const id = typeof header.id === 'string' ? header.id : null;
|
|
851
|
+
if (keep !== undefined) {
|
|
852
|
+
if (id !== keep) { plans.push({ rel: f.rel, id, status: 'skip', reason: `--keep ${keep} 未选中` }); continue; }
|
|
853
|
+
if (seenIds.has(id)) { plans.push({ rel: f.rel, id, status: 'duplicate', reason: '重复 id(保留首个,跳过)' }); continue; }
|
|
854
|
+
}
|
|
855
|
+
seenIds.add(id);
|
|
856
|
+
const cwd = typeof header.cwd === 'string' ? header.cwd : null;
|
|
857
|
+
let newCwd = cwd;
|
|
858
|
+
let changed = false;
|
|
859
|
+
if (cwd) {
|
|
860
|
+
for (const m of maps) {
|
|
861
|
+
if (newCwd.startsWith(m.from)) { newCwd = m.to + newCwd.slice(m.from.length); changed = true; }
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (changed) plans.push({ rel: f.rel, id, status: 'fix', cwd, newCwd, file: f, lines });
|
|
865
|
+
else plans.push({ rel: f.rel, id, status: 'unchanged', cwd });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const fixable = plans.filter((p) => p.status === 'fix');
|
|
869
|
+
if (!fix) {
|
|
870
|
+
console.log(`sessions repair(dry-run,未写入)——扫描 ${files.length} 个会话日志`);
|
|
871
|
+
for (const p of plans) {
|
|
872
|
+
if (p.status === 'fix') console.log(` 🔧 ${p.rel}\n ${p.cwd}\n → ${p.newCwd}`);
|
|
873
|
+
else if (p.status === 'skip') console.log(` ⏭️ ${p.rel}(${p.reason})`);
|
|
874
|
+
else if (p.status === 'duplicate') console.log(` ⏭️ ${p.rel}(${p.reason})`);
|
|
875
|
+
else if (p.status === 'unchanged' && !keep) console.log(` ✓ ${p.rel}(无变化${p.cwd ? `,cwd=${p.cwd}` : ''})`);
|
|
876
|
+
}
|
|
877
|
+
console.log(fixable.length
|
|
878
|
+
? `\n确认后执行: node rescue.mjs sessions repair --fix ${argv.filter((a) => a !== '--fix').join(' ')}`
|
|
879
|
+
: '\n没有需要修复的 cwd。');
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const stamp = stampNow();
|
|
884
|
+
const repaired = [];
|
|
885
|
+
const failed = [];
|
|
886
|
+
for (const p of fixable) {
|
|
887
|
+
const backup = `${p.file.abs}.repair-${stamp}`;
|
|
888
|
+
try {
|
|
889
|
+
await fs.copyFile(p.file.abs, backup);
|
|
890
|
+
const newHeaderLine = JSON.stringify({ ...JSON.parse(p.lines[0]), cwd: p.newCwd });
|
|
891
|
+
let outBuf;
|
|
892
|
+
if (p.file.abs.endsWith('.zstd')) {
|
|
893
|
+
const raw = await fs.readFile(p.file.abs);
|
|
894
|
+
const frames = walkZstdFrames(raw);
|
|
895
|
+
if (!frames.length) throw new Error('无 zstd 帧');
|
|
896
|
+
const newHead = zlib.zstdCompressSync(`${newHeaderLine}\n`);
|
|
897
|
+
outBuf = Buffer.concat([newHead, raw.subarray(frames[0][1])]);
|
|
898
|
+
} else {
|
|
899
|
+
const lines = p.lines.slice();
|
|
900
|
+
lines[0] = newHeaderLine;
|
|
901
|
+
outBuf = Buffer.from(`${lines.join('\n')}\n`, 'utf8');
|
|
902
|
+
}
|
|
903
|
+
const tmp = `${p.file.abs}.tmp-${stampNow()}`;
|
|
904
|
+
await fs.writeFile(tmp, outBuf);
|
|
905
|
+
await fs.rename(tmp, p.file.abs);
|
|
906
|
+
repaired.push(p.rel);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
failed.push(`${p.rel}(${String(err && err.message ? err.message : err)})`);
|
|
909
|
+
await fs.copyFile(backup, p.file.abs).catch(() => {});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
console.log(repaired.length ? `✅ 已修复 ${repaired.length} 个会话日志的 cwd${failed.length ? `;${failed.length} 个失败已回滚` : ''}` : '没有可修复的 cwd。');
|
|
913
|
+
if (failed.length) { console.log(`❌ 失败: ${failed.join('; ')}`); process.exitCode = 1; }
|
|
914
|
+
}
|
|
915
|
+
|
|
746
916
|
// ---------- CLI ----------
|
|
747
917
|
|
|
748
918
|
const HELP = `dsh-rescue —— dsh-backup 的进程外救援通道(零依赖)
|
|
@@ -754,6 +924,9 @@ const HELP = `dsh-rescue —— dsh-backup 的进程外救援通道(零依赖
|
|
|
754
924
|
node rescue.mjs restore <前缀|latest> 恢复预览;确认无误后加 --yes 执行
|
|
755
925
|
node rescue.mjs doctor 会话日志体检
|
|
756
926
|
node rescue.mjs doctor --repair [前缀] 从备份定点修复损坏的会话日志
|
|
927
|
+
node rescue.mjs reinstall [--package <名>] [--code <6位>] 重装全局 CLI(确认码门禁)
|
|
928
|
+
node rescue.mjs sessions repair [--fix] [--map old=new]... [--keep <id>] 会话首帧 cwd 重定位
|
|
929
|
+
node rescue.mjs recover-stale-lock 回收残留死锁(owner 已死才回收,不误踢活锁)
|
|
757
930
|
node rescue.mjs serve [--port N] 同无参数:启动救援网页
|
|
758
931
|
|
|
759
932
|
选项:
|
|
@@ -811,6 +984,13 @@ async function main() {
|
|
|
811
984
|
if (r.corruptCount) process.exitCode = 1;
|
|
812
985
|
return;
|
|
813
986
|
}
|
|
987
|
+
if (cmd === 'reinstall') return reinstall(root, argv);
|
|
988
|
+
if (cmd === 'sessions') {
|
|
989
|
+
const sub = pos[1];
|
|
990
|
+
if (sub !== 'repair') return console.error(`未知 sessions 子命令: ${sub || '(缺省)'}(支持 repair)\n\n${HELP}`), void (process.exitCode = 1);
|
|
991
|
+
return sessionsRepair(root, dshHome, argv);
|
|
992
|
+
}
|
|
993
|
+
if (cmd === 'recover-stale-lock') return recoverStaleLock(root);
|
|
814
994
|
console.error(`未知命令: ${cmd}\n\n${HELP}`);
|
|
815
995
|
process.exitCode = 1;
|
|
816
996
|
}
|