dsh-plugin-pack 0.0.0-stage → 1.0.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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 流式写 ZIP。
3
+ *
4
+ * 为什么单独做:默认导出的插件集合在真实 profile 上是几百 MiB,
5
+ * 用 writeZip(Buffer.concat 全部条目)峰值内存会翻倍。
6
+ *
7
+ * 关键设计:**全程异步**。
8
+ * 压缩用 zlib 的 callback 版本而不是 `deflateRawSync` —— 同步压缩会
9
+ * 把整个 Node 事件循环卡住,导致 dsh 的对话、其它接口全部无响应。
10
+ * 实测 64 MiB 同步 gzip 耗时 413ms,期间定时器一次都没触发。
11
+ * 导出几百 MiB 就是好几秒的完全冻结,所以这里必须异步。
12
+ *
13
+ * 写盘也用 fs.promises,让出事件循环给其它请求。
14
+ *
15
+ * @module dsh-plugin-pack/lib/zip-stream
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { promisify } from 'node:util';
20
+ import { deflateRaw as deflateRawCb } from 'node:zlib';
21
+
22
+ import { crc32 } from './zip.js';
23
+
24
+ const deflateRaw = promisify(deflateRawCb);
25
+
26
+ const SIG_LOCAL = 0x04034b50;
27
+ const SIG_CENTRAL = 0x02014b50;
28
+ const SIG_EOCD = 0x06054b50;
29
+ const FLAG_UTF8 = 0x0800;
30
+ const METHOD_STORE = 0;
31
+ const METHOD_DEFLATE = 8;
32
+ const U16_MAX = 0xffff;
33
+ const U32_MAX = 0xffffffff;
34
+ const DOS_TIME = 0;
35
+ const DOS_DATE = 0x0021; // 1980-01-01
36
+
37
+ /** 已压缩过的条目:再 deflate 一遍几乎没有收益,直接 store。 */
38
+ const ALREADY_COMPRESSED = /\.(tgz|tar\.gz|gz|zip|7z|br|xz|png|jpg|jpeg|webp|mp4|webm|woff2?)$/i;
39
+
40
+ /**
41
+ * 把条目流写成 zip 文件(异步)。
42
+ *
43
+ * @param {Iterable<{name: string, data?: Buffer|Uint8Array|string, file?: string, store?: boolean}>|AsyncIterable<any>} entries
44
+ * @param {string} outFile
45
+ * @param {{ maxBytes?: number, onEntry?: (name: string, bytes: number) => void }} [options]
46
+ * @returns {Promise<{ bytes: number, entries: number }>}
47
+ */
48
+ export async function writeZipStream(entries, outFile, options = {}) {
49
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 ? Math.floor(options.maxBytes) : Infinity;
50
+ const onEntry = typeof options.onEntry === 'function' ? options.onEntry : () => {};
51
+
52
+ // 只在目录不存在时才创建(对已存在的盘符根 mkdir 会 EPERM)
53
+ const outDir = path.dirname(path.resolve(outFile));
54
+ if (!fs.existsSync(outDir)) await fs.promises.mkdir(outDir, { recursive: true });
55
+
56
+ const handle = await fs.promises.open(outFile, 'w');
57
+ /** @type {Buffer[]} 中央目录分片(只有 header + 文件名,很小) */
58
+ const centralParts = [];
59
+ let offset = 0;
60
+ let count = 0;
61
+ let total = 0;
62
+
63
+ const write = async (buffer) => {
64
+ await handle.write(buffer);
65
+ offset += buffer.length;
66
+ };
67
+
68
+ try {
69
+ for await (const item of entries) {
70
+ if (!item || typeof item.name !== 'string' || !item.name) {
71
+ throw new Error('writeZipStream: 条目缺少 name');
72
+ }
73
+ const name = item.name.replace(/\\/g, '/');
74
+ if (name.endsWith('/')) continue;
75
+ if (name.includes('\0')) throw new Error(`writeZipStream: 条目名含 NUL: ${item.name}`);
76
+
77
+ const data = item.file ? await fs.promises.readFile(item.file) : toBuf(item.data);
78
+ const nameBuf = Buffer.from(name, 'utf8');
79
+ if (nameBuf.length > U16_MAX) throw new Error(`writeZipStream: 条目名过长: ${name}`);
80
+ if (data.length > U32_MAX || offset > U32_MAX) throw new Error('不支持 ZIP64');
81
+
82
+ const store = item.store === true || (item.store === undefined && ALREADY_COMPRESSED.test(name));
83
+ const method = store ? METHOD_STORE : METHOD_DEFLATE;
84
+ // 异步压缩:让出事件循环,宿主还能处理对话等其它请求
85
+ const payload = store ? data : await deflateRaw(data, { level: 9 });
86
+ const checksum = crc32(data);
87
+
88
+ const local = Buffer.alloc(30);
89
+ local.writeUInt32LE(SIG_LOCAL, 0);
90
+ local.writeUInt16LE(20, 4);
91
+ local.writeUInt16LE(FLAG_UTF8, 6);
92
+ local.writeUInt16LE(method, 8);
93
+ local.writeUInt16LE(DOS_TIME, 10);
94
+ local.writeUInt16LE(DOS_DATE, 12);
95
+ local.writeUInt32LE(checksum, 14);
96
+ local.writeUInt32LE(payload.length, 18);
97
+ local.writeUInt32LE(data.length, 22);
98
+ local.writeUInt16LE(nameBuf.length, 26);
99
+ local.writeUInt16LE(0, 28);
100
+
101
+ const central = Buffer.alloc(46);
102
+ central.writeUInt32LE(SIG_CENTRAL, 0);
103
+ central.writeUInt16LE(20, 4);
104
+ central.writeUInt16LE(20, 6);
105
+ central.writeUInt16LE(FLAG_UTF8, 8);
106
+ central.writeUInt16LE(method, 10);
107
+ central.writeUInt16LE(DOS_TIME, 12);
108
+ central.writeUInt16LE(DOS_DATE, 14);
109
+ central.writeUInt32LE(checksum, 16);
110
+ central.writeUInt32LE(payload.length, 20);
111
+ central.writeUInt32LE(data.length, 24);
112
+ central.writeUInt16LE(nameBuf.length, 28);
113
+ central.writeUInt16LE(0, 30); // extra
114
+ central.writeUInt16LE(0, 32); // comment
115
+ central.writeUInt16LE(0, 34);
116
+ central.writeUInt16LE(0, 36);
117
+ central.writeUInt32LE(0, 38);
118
+ central.writeUInt32LE(offset, 42);
119
+
120
+ await write(local);
121
+ await write(nameBuf);
122
+ await write(payload);
123
+
124
+ centralParts.push(central, nameBuf);
125
+ count += 1;
126
+ total += data.length;
127
+ if (count > U16_MAX) throw new Error('不支持 ZIP64');
128
+ if (total > maxBytes) {
129
+ const error = new Error(
130
+ `整合包超过体积上限(已到 ${(total / 1048576).toFixed(1)} MiB,上限 ${(maxBytes / 1048576).toFixed(1)} MiB)`,
131
+ );
132
+ error.code = 'TOO_LARGE';
133
+ throw error;
134
+ }
135
+ onEntry(name, data.length);
136
+ }
137
+
138
+ const centralStart = offset;
139
+ for (const part of centralParts) await write(part);
140
+ const centralSize = offset - centralStart;
141
+
142
+ const eocd = Buffer.alloc(22);
143
+ eocd.writeUInt32LE(SIG_EOCD, 0);
144
+ eocd.writeUInt16LE(0, 4);
145
+ eocd.writeUInt16LE(0, 6);
146
+ eocd.writeUInt16LE(count, 8);
147
+ eocd.writeUInt16LE(count, 10);
148
+ eocd.writeUInt32LE(centralSize, 12);
149
+ eocd.writeUInt32LE(centralStart, 16);
150
+ eocd.writeUInt16LE(0, 20);
151
+ await write(eocd);
152
+
153
+ return { bytes: offset, entries: count };
154
+ } finally {
155
+ await handle.close();
156
+ }
157
+ }
158
+
159
+ /** 统一输入类型。 */
160
+ function toBuf(value) {
161
+ if (Buffer.isBuffer(value)) return value;
162
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
163
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
164
+ if (ArrayBuffer.isView(value)) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
165
+ throw new Error(`writeZipStream: 不支持的数据类型 ${Object.prototype.toString.call(value)}`);
166
+ }
package/lib/zip.js ADDED
@@ -0,0 +1,514 @@
1
+ /**
2
+ * 零依赖 ZIP 读写模块。
3
+ *
4
+ * 只使用 node: 内置模块(node:zlib / node:path),不引入 fflate、archiver、jszip、yauzl 等第三方库。
5
+ *
6
+ * 支持范围:
7
+ * - 写入:method 0(store)与 method 8(deflate,level 9);通用标志位 bit 11(UTF-8 文件名);
8
+ * 不写 data descriptor,CRC 与长度直接写进 local file header。
9
+ * - 读取:以 EOCD → central directory 为准,不顺序扫 local header;支持 method 0 / 8;
10
+ * 支持带 bit 3(data descriptor)的 zip,长度与 CRC 取自 central directory;
11
+ * 目录条目(name 以 `/` 结尾)直接跳过。
12
+ *
13
+ * 明确不支持:ZIP64、分卷、加密条目,以及 method 12(bzip2)、14(lzma)、99(AES)等压缩方式。
14
+ *
15
+ * @module dsh-plugin-pack/lib/zip
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import { deflateRawSync, inflateRawSync } from 'node:zlib';
20
+
21
+ /** local file header 签名 `PK\x03\x04`。 */
22
+ const SIG_LOCAL = 0x04034b50;
23
+ /** central directory file header 签名 `PK\x01\x02`。 */
24
+ const SIG_CENTRAL = 0x02014b50;
25
+ /** end of central directory 签名 `PK\x05\x06`。 */
26
+ const SIG_EOCD = 0x06054b50;
27
+
28
+ /** 通用标志位 bit 11:文件名使用 UTF-8。 */
29
+ const FLAG_UTF8 = 0x0800;
30
+ /** 通用标志位 bit 0:条目已加密。 */
31
+ const FLAG_ENCRYPTED = 0x0001;
32
+
33
+ /** 压缩方式:原样存储。 */
34
+ const METHOD_STORE = 0;
35
+ /** 压缩方式:deflate。 */
36
+ const METHOD_DEFLATE = 8;
37
+
38
+ const U16_MAX = 0xffff;
39
+ const U32_MAX = 0xffffffff;
40
+
41
+ /** 固定 DOS 时间戳 1980-01-01 00:00:00,保证输出字节可复现。 */
42
+ const DOS_TIME = 0;
43
+ const DOS_DATE = 0x0021;
44
+
45
+ /** ZIP64 统一错误文案。 */
46
+ const ERR_ZIP64 = '不支持 ZIP64';
47
+
48
+ /**
49
+ * @typedef {Object} ZipEntryInput
50
+ * @property {string} name 条目路径,写入时统一用 `/` 分隔
51
+ * @property {Buffer|Uint8Array|ArrayBuffer|string} data 条目内容
52
+ * @property {boolean} [store] 为 true 时用 method 0 原样存储,否则 deflate
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} ZipEntry
57
+ * @property {string} name 条目路径
58
+ * @property {Buffer} data 解压后的内容
59
+ * @property {number} method 压缩方式(0 或 8)
60
+ * @property {number} compressedSize 压缩后字节数
61
+ * @property {number} size 原始字节数
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} ZipCentralRecord
66
+ * @property {string} name 条目路径
67
+ * @property {number} flags 通用标志位
68
+ * @property {number} method 压缩方式
69
+ * @property {number} checksum central directory 中记录的 CRC-32
70
+ * @property {number} compressedSize 压缩后字节数
71
+ * @property {number} size 原始字节数
72
+ * @property {number} localOffset local file header 的偏移
73
+ */
74
+
75
+ /**
76
+ * CRC-32(IEEE 802.3,多项式 0xEDB88320)查表,模块加载时算一次。
77
+ *
78
+ * @type {Uint32Array}
79
+ */
80
+ const CRC_TABLE = (() => {
81
+ const table = new Uint32Array(256);
82
+ for (let index = 0; index < 256; index += 1) {
83
+ let value = index;
84
+ for (let bit = 0; bit < 8; bit += 1) {
85
+ value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
86
+ }
87
+ table[index] = value >>> 0;
88
+ }
89
+ return table;
90
+ })();
91
+
92
+ /**
93
+ * 把任意受支持的输入统一成 Buffer(不复制已有 Buffer)。
94
+ *
95
+ * @param {Buffer|Uint8Array|ArrayBuffer|string} value
96
+ * @returns {Buffer}
97
+ */
98
+ function toBuffer(value) {
99
+ if (Buffer.isBuffer(value)) return value;
100
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
101
+ if (value instanceof ArrayBuffer) return Buffer.from(value);
102
+ if (ArrayBuffer.isView(value)) {
103
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
104
+ }
105
+ throw new Error(`不支持的数据类型: ${Object.prototype.toString.call(value)}`);
106
+ }
107
+
108
+ /**
109
+ * 计算 CRC-32(unsigned 32-bit)。
110
+ *
111
+ * @param {Buffer|Uint8Array|ArrayBuffer|string} buffer 待计算的数据
112
+ * @returns {number} 0 ~ 0xFFFFFFFF 的整数
113
+ */
114
+ export function crc32(buffer) {
115
+ const buf = toBuffer(buffer);
116
+ let crc = 0xffffffff;
117
+ for (let index = 0; index < buf.length; index += 1) {
118
+ crc = CRC_TABLE[(crc ^ buf[index]) & 0xff] ^ (crc >>> 8);
119
+ }
120
+ return (crc ^ 0xffffffff) >>> 0;
121
+ }
122
+
123
+ /**
124
+ * 生成一个完整的 ZIP 文件。
125
+ *
126
+ * 结构:local file header + data(逐条)+ central directory + EOCD。
127
+ * 所有字段按 ZIP 规范小端序写入;不写 data descriptor;空目录条目跳过。
128
+ *
129
+ * @param {ZipEntryInput[]} entries 条目列表
130
+ * @returns {Buffer} 完整 zip 字节
131
+ */
132
+ export function writeZip(entries) {
133
+ if (!Array.isArray(entries)) {
134
+ throw new Error('writeZip: entries 必须是数组');
135
+ }
136
+
137
+ /** @type {Buffer[]} local header / 文件名 / 数据分片,最后一次性拼接。 */
138
+ const localParts = [];
139
+ /** @type {Buffer[]} central directory 分片。 */
140
+ const centralParts = [];
141
+ let offset = 0;
142
+ let count = 0;
143
+
144
+ for (const raw of entries) {
145
+ if (raw === null || typeof raw !== 'object') {
146
+ throw new Error('writeZip: 每个条目必须是对象');
147
+ }
148
+ if (typeof raw.name !== 'string' || raw.name.length === 0) {
149
+ throw new Error('writeZip: 条目缺少非空的 name');
150
+ }
151
+ const name = raw.name.replace(/\\/g, '/');
152
+ if (name.includes('\0')) {
153
+ throw new Error(`writeZip: 条目名包含 NUL 字符: ${raw.name}`);
154
+ }
155
+ // 目录条目(name 以 / 结尾)不写入,读取侧同样会跳过。
156
+ if (name.endsWith('/')) continue;
157
+ if (raw.data === undefined || raw.data === null) {
158
+ throw new Error(`writeZip: 条目缺少 data: ${name}`);
159
+ }
160
+
161
+ const data = toBuffer(raw.data);
162
+ const nameBuf = Buffer.from(name, 'utf8');
163
+ if (nameBuf.length > U16_MAX) {
164
+ throw new Error(`writeZip: 条目名过长: ${name}`);
165
+ }
166
+ if (data.length > U32_MAX) {
167
+ throw new Error(ERR_ZIP64);
168
+ }
169
+ if (offset > U32_MAX) {
170
+ throw new Error(ERR_ZIP64);
171
+ }
172
+
173
+ const store = raw.store === true;
174
+ const method = store ? METHOD_STORE : METHOD_DEFLATE;
175
+ const payload = store ? data : deflateRawSync(data, { level: 9 });
176
+ if (payload.length > U32_MAX) {
177
+ throw new Error(ERR_ZIP64);
178
+ }
179
+ const checksum = crc32(data);
180
+
181
+ const local = Buffer.alloc(30);
182
+ local.writeUInt32LE(SIG_LOCAL, 0);
183
+ local.writeUInt16LE(20, 4); // version needed to extract: 2.0
184
+ local.writeUInt16LE(FLAG_UTF8, 6); // bit 11: UTF-8 文件名
185
+ local.writeUInt16LE(method, 8);
186
+ local.writeUInt16LE(DOS_TIME, 10);
187
+ local.writeUInt16LE(DOS_DATE, 12);
188
+ local.writeUInt32LE(checksum, 14);
189
+ local.writeUInt32LE(payload.length, 18);
190
+ local.writeUInt32LE(data.length, 22);
191
+ local.writeUInt16LE(nameBuf.length, 26);
192
+ local.writeUInt16LE(0, 28); // extra field 长度
193
+
194
+ localParts.push(local, nameBuf, payload);
195
+
196
+ const central = Buffer.alloc(46);
197
+ central.writeUInt32LE(SIG_CENTRAL, 0);
198
+ central.writeUInt16LE(20, 4); // version made by: MS-DOS, 2.0
199
+ central.writeUInt16LE(20, 6); // version needed to extract: 2.0
200
+ central.writeUInt16LE(FLAG_UTF8, 8);
201
+ central.writeUInt16LE(method, 10);
202
+ central.writeUInt16LE(DOS_TIME, 12);
203
+ central.writeUInt16LE(DOS_DATE, 14);
204
+ central.writeUInt32LE(checksum, 16);
205
+ central.writeUInt32LE(payload.length, 20);
206
+ central.writeUInt32LE(data.length, 24);
207
+ central.writeUInt16LE(nameBuf.length, 28);
208
+ central.writeUInt16LE(0, 30); // extra field 长度
209
+ central.writeUInt16LE(0, 32); // comment 长度
210
+ central.writeUInt16LE(0, 34); // 起始磁盘号
211
+ central.writeUInt16LE(0, 36); // internal attributes
212
+ central.writeUInt32LE(0, 38); // external attributes
213
+ central.writeUInt32LE(offset, 42); // local header 偏移
214
+
215
+ centralParts.push(central, nameBuf);
216
+
217
+ offset += local.length + nameBuf.length + payload.length;
218
+ count += 1;
219
+ }
220
+
221
+ if (count > U16_MAX) {
222
+ throw new Error(ERR_ZIP64);
223
+ }
224
+ if (offset > U32_MAX) {
225
+ throw new Error(ERR_ZIP64);
226
+ }
227
+
228
+ const central = Buffer.concat(centralParts);
229
+ if (central.length > U32_MAX) {
230
+ throw new Error(ERR_ZIP64);
231
+ }
232
+
233
+ const eocd = Buffer.alloc(22);
234
+ eocd.writeUInt32LE(SIG_EOCD, 0);
235
+ eocd.writeUInt16LE(0, 4); // 当前磁盘号
236
+ eocd.writeUInt16LE(0, 6); // central directory 起始磁盘号
237
+ eocd.writeUInt16LE(count, 8); // 本磁盘条目数
238
+ eocd.writeUInt16LE(count, 10); // 总条目数
239
+ eocd.writeUInt32LE(central.length, 12);
240
+ eocd.writeUInt32LE(offset, 16);
241
+ eocd.writeUInt16LE(0, 20); // comment 长度
242
+
243
+ return Buffer.concat([...localParts, central, eocd]);
244
+ }
245
+
246
+ /**
247
+ * 从尾部向前定位 EOCD,优先返回注释长度与文件尾严格对齐的那条记录。
248
+ *
249
+ * @param {Buffer} buf
250
+ * @returns {number} EOCD 起始偏移
251
+ */
252
+ function findEocd(buf) {
253
+ if (buf.length < 22) {
254
+ throw new Error('ZIP 解析失败: 文件长度不足,缺少 EOCD 记录');
255
+ }
256
+ const lowest = Math.max(0, buf.length - 22 - U16_MAX);
257
+ let loose = -1;
258
+ for (let index = buf.length - 22; index >= lowest; index -= 1) {
259
+ if (buf.readUInt32LE(index) !== SIG_EOCD) continue;
260
+ const commentLength = buf.readUInt16LE(index + 20);
261
+ if (index + 22 + commentLength === buf.length) return index;
262
+ if (loose === -1 && index + 22 + commentLength <= buf.length) loose = index;
263
+ }
264
+ if (loose !== -1) return loose;
265
+ throw new Error('ZIP 解析失败: 找不到 EOCD 记录');
266
+ }
267
+
268
+ /**
269
+ * 读取并校验 EOCD 字段(含 ZIP64 / 分卷检查)。
270
+ *
271
+ * @param {Buffer} buf
272
+ * @returns {{ totalEntries: number, centralSize: number, centralOffset: number }}
273
+ */
274
+ function readEocd(buf) {
275
+ const eocd = findEocd(buf);
276
+ const diskNumber = buf.readUInt16LE(eocd + 4);
277
+ const centralDisk = buf.readUInt16LE(eocd + 6);
278
+ const entriesOnDisk = buf.readUInt16LE(eocd + 8);
279
+ const totalEntries = buf.readUInt16LE(eocd + 10);
280
+ const centralSize = buf.readUInt32LE(eocd + 12);
281
+ const centralOffset = buf.readUInt32LE(eocd + 16);
282
+
283
+ if (
284
+ totalEntries === U16_MAX ||
285
+ entriesOnDisk === U16_MAX ||
286
+ centralSize === U32_MAX ||
287
+ centralOffset === U32_MAX
288
+ ) {
289
+ throw new Error(ERR_ZIP64);
290
+ }
291
+ if (diskNumber !== 0 || centralDisk !== 0) {
292
+ throw new Error('不支持分卷 ZIP');
293
+ }
294
+ if (centralOffset + centralSize > buf.length) {
295
+ throw new Error('ZIP 解析失败: central directory 超出文件范围');
296
+ }
297
+ return { totalEntries, centralSize, centralOffset };
298
+ }
299
+
300
+ /**
301
+ * 解析 central directory,返回文件条目(跳过目录条目)。
302
+ *
303
+ * 只读取元数据,不做任何解压,供 readZip / zipStats 共用。
304
+ *
305
+ * @param {Buffer} buf
306
+ * @returns {ZipCentralRecord[]}
307
+ */
308
+ function parseCentralDirectory(buf) {
309
+ const { totalEntries, centralSize, centralOffset } = readEocd(buf);
310
+ const centralEnd = centralOffset + centralSize;
311
+ /** @type {ZipCentralRecord[]} */
312
+ const records = [];
313
+ let cursor = centralOffset;
314
+
315
+ for (let index = 0; index < totalEntries; index += 1) {
316
+ if (cursor + 46 > centralEnd) {
317
+ throw new Error(`ZIP 解析失败: central directory 第 ${index + 1} 条记录被截断`);
318
+ }
319
+ if (buf.readUInt32LE(cursor) !== SIG_CENTRAL) {
320
+ throw new Error(`ZIP 解析失败: central directory 第 ${index + 1} 条记录签名错误`);
321
+ }
322
+
323
+ const flags = buf.readUInt16LE(cursor + 8);
324
+ const method = buf.readUInt16LE(cursor + 10);
325
+ const checksum = buf.readUInt32LE(cursor + 16);
326
+ const compressedSize = buf.readUInt32LE(cursor + 20);
327
+ const size = buf.readUInt32LE(cursor + 24);
328
+ const nameLength = buf.readUInt16LE(cursor + 28);
329
+ const extraLength = buf.readUInt16LE(cursor + 30);
330
+ const commentLength = buf.readUInt16LE(cursor + 32);
331
+ const localOffset = buf.readUInt32LE(cursor + 42);
332
+ const next = cursor + 46 + nameLength + extraLength + commentLength;
333
+
334
+ if (next > centralEnd) {
335
+ throw new Error(`ZIP 解析失败: central directory 第 ${index + 1} 条记录越界`);
336
+ }
337
+ if (compressedSize === U32_MAX || size === U32_MAX || localOffset === U32_MAX) {
338
+ throw new Error(ERR_ZIP64);
339
+ }
340
+
341
+ const name = buf.toString('utf8', cursor + 46, cursor + 46 + nameLength);
342
+ cursor = next;
343
+
344
+ // 目录条目直接跳过,不返回。
345
+ if (name.endsWith('/')) continue;
346
+
347
+ records.push({ name, flags, method, checksum, compressedSize, size, localOffset });
348
+ }
349
+
350
+ return records;
351
+ }
352
+
353
+ /**
354
+ * 读取 ZIP 的条目元数据,**不解压任何数据**。
355
+ *
356
+ * 大包(几百 MB)用它先拿到清单,再用 extractEntry 逐条解压写盘,
357
+ * 峰值内存只有单个文件,而不是整包。
358
+ *
359
+ * @param {Buffer|Uint8Array|ArrayBuffer} buffer zip 字节
360
+ * @returns {ZipCentralRecord[]} 文件条目(已跳过目录条目)
361
+ */
362
+ export function readZipMeta(buffer) {
363
+ const buf = toBuffer(buffer);
364
+ return parseCentralDirectory(buf);
365
+ }
366
+
367
+ /**
368
+ * 从 zip 里解出单条记录的内容。
369
+ *
370
+ * @param {Buffer|Uint8Array|ArrayBuffer} buffer zip 字节
371
+ * @param {ZipCentralRecord} record readZipMeta 返回的记录
372
+ * @returns {Buffer} 解压后的内容
373
+ */
374
+ export function extractEntry(buffer, record) {
375
+ const buf = toBuffer(buffer);
376
+ if ((record.flags & FLAG_ENCRYPTED) !== 0) {
377
+ throw new Error(`不支持加密条目: ${record.name}`);
378
+ }
379
+ if (record.method !== METHOD_STORE && record.method !== METHOD_DEFLATE) {
380
+ throw new Error(`不支持的压缩方式: ${record.method} (${record.name})`);
381
+ }
382
+
383
+ const local = record.localOffset;
384
+ if (local + 30 > buf.length) {
385
+ throw new Error(`ZIP 解析失败: 条目 local header 越界 (${record.name})`);
386
+ }
387
+ if (buf.readUInt32LE(local) !== SIG_LOCAL) {
388
+ throw new Error(`ZIP 解析失败: 条目 local header 签名错误 (${record.name})`);
389
+ }
390
+
391
+ const nameLength = buf.readUInt16LE(local + 26);
392
+ const extraLength = buf.readUInt16LE(local + 28);
393
+ const dataStart = local + 30 + nameLength + extraLength;
394
+ const dataEnd = dataStart + record.compressedSize;
395
+ if (dataEnd > buf.length) {
396
+ throw new Error(`ZIP 解析失败: 条目数据越界 (${record.name})`);
397
+ }
398
+ const raw = buf.subarray(dataStart, dataEnd);
399
+
400
+ /** @type {Buffer} */
401
+ let data;
402
+ if (record.method === METHOD_STORE) {
403
+ if (record.compressedSize !== record.size) {
404
+ throw new Error(`ZIP 解析失败: store 条目长度不一致 (${record.name})`);
405
+ }
406
+ data = Buffer.from(raw);
407
+ } else if (record.compressedSize === 0 && record.size === 0) {
408
+ data = Buffer.alloc(0);
409
+ } else {
410
+ try {
411
+ data = inflateRawSync(raw);
412
+ } catch (error) {
413
+ const reason = error instanceof Error ? error.message : String(error);
414
+ throw new Error(`ZIP 解压失败: ${record.name} (${reason})`);
415
+ }
416
+ if (data.length !== record.size) {
417
+ throw new Error(`ZIP 解析失败: 解压后长度不一致 (${record.name})`);
418
+ }
419
+ }
420
+
421
+ if (crc32(data) !== record.checksum) {
422
+ throw new Error(`ZIP 校验失败: CRC 不匹配 (${record.name})`);
423
+ }
424
+ return data;
425
+ }
426
+
427
+ /**
428
+ * 读取 ZIP 的全部文件条目。
429
+ *
430
+ * 数据位置按 central directory 记录的 local header 偏移与压缩长度截取,
431
+ * 因此带 bit 3(data descriptor)的 zip 也能正确读取。
432
+ *
433
+ * @param {Buffer|Uint8Array|ArrayBuffer} buffer zip 字节
434
+ * @returns {ZipEntry[]}
435
+ */
436
+ export function readZip(buffer) {
437
+ const buf = toBuffer(buffer);
438
+ const records = parseCentralDirectory(buf);
439
+ /** @type {ZipEntry[]} */
440
+ const entries = [];
441
+ for (const record of records) {
442
+ const data = extractEntry(buf, record);
443
+ entries.push({
444
+ name: record.name,
445
+ data,
446
+ method: record.method,
447
+ compressedSize: record.compressedSize,
448
+ size: record.size,
449
+ });
450
+ }
451
+ return entries;
452
+ }
453
+
454
+ /**
455
+ * 快速统计 zip:只解析 EOCD + central directory,不解压任何数据。
456
+ *
457
+ * @param {Buffer|Uint8Array|ArrayBuffer} buffer zip 字节
458
+ * @returns {{ entries: number, bytes: number, names: string[] }} 条目数、原始字节总数、条目名
459
+ */
460
+ export function zipStats(buffer) {
461
+ const buf = toBuffer(buffer);
462
+ const records = parseCentralDirectory(buf);
463
+ const names = new Array(records.length);
464
+ let bytes = 0;
465
+ for (let index = 0; index < records.length; index += 1) {
466
+ names[index] = records[index].name;
467
+ bytes += records[index].size;
468
+ }
469
+ return { entries: records.length, bytes, names };
470
+ }
471
+
472
+ /**
473
+ * 把 zip 内的条目名安全地拼到 root 目录下。
474
+ *
475
+ * 拒绝:以 `/` 或 `\` 开头的绝对路径、盘符开头(`C:`)、含 `..` 路径段、含 NUL 字符,
476
+ * 以及 resolve 后逃出 root 的路径。
477
+ *
478
+ * @param {string} root 解压目标根目录
479
+ * @param {string} name zip 条目名
480
+ * @returns {string} root 之内的绝对路径
481
+ */
482
+ export function safeJoin(root, name) {
483
+ if (typeof root !== 'string' || root.length === 0) {
484
+ throw new Error('safeJoin: root 必须是非空字符串');
485
+ }
486
+ if (typeof name !== 'string' || name.length === 0) {
487
+ throw new Error('safeJoin: name 必须是非空字符串');
488
+ }
489
+
490
+ const normalized = name.replace(/\\/g, '/');
491
+ if (normalized.includes('\0')) {
492
+ throw new Error(`非法条目路径(包含 NUL 字符): ${name}`);
493
+ }
494
+ if (normalized.startsWith('/')) {
495
+ throw new Error(`非法条目路径(绝对路径): ${name}`);
496
+ }
497
+ if (/^[A-Za-z]:/.test(normalized)) {
498
+ throw new Error(`非法条目路径(盘符): ${name}`);
499
+ }
500
+
501
+ const segments = normalized.split('/');
502
+ if (segments.includes('..')) {
503
+ throw new Error(`非法条目路径(路径穿越): ${name}`);
504
+ }
505
+ const cleaned = segments.filter((segment) => segment.length > 0 && segment !== '.');
506
+
507
+ const resolvedRoot = path.resolve(root);
508
+ const target = path.resolve(resolvedRoot, ...cleaned);
509
+ const relative = path.relative(resolvedRoot, target);
510
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
511
+ throw new Error(`非法条目路径(逃出 root): ${name}`);
512
+ }
513
+ return target;
514
+ }