@push.rocks/smartarchive 5.2.3 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.smartarchive.d.ts +1 -1
  3. package/dist_ts/classes.smartarchive.js +2 -2
  4. package/dist_ts/classes.ziptools.d.ts +18 -0
  5. package/dist_ts/classes.ziptools.js +70 -0
  6. package/dist_ts/index.d.ts +1 -0
  7. package/dist_ts/index.js +3 -1
  8. package/dist_ts_shared/bzip2/index.js +2 -2
  9. package/dist_ts_shared/classes.ziptools.d.ts +6 -13
  10. package/dist_ts_shared/classes.ziptools.js +511 -54
  11. package/dist_ts_shared/errors.d.ts +25 -0
  12. package/dist_ts_shared/errors.js +28 -1
  13. package/dist_ts_shared/index.d.ts +1 -1
  14. package/dist_ts_shared/index.js +2 -2
  15. package/dist_ts_shared/interfaces.d.ts +35 -0
  16. package/dist_ts_shared/plugins.ziptools.d.ts +3 -0
  17. package/dist_ts_shared/plugins.ziptools.js +4 -0
  18. package/dist_ts_web/00_commitinfo_data.js +1 -1
  19. package/dist_ts_web/index.d.ts +1 -1
  20. package/dist_ts_web/index.js +1 -1
  21. package/dist_ts_web/plugins.d.ts +1 -1
  22. package/dist_ts_web/plugins.js +1 -1
  23. package/dist_ts_web/zip.d.ts +3 -0
  24. package/dist_ts_web/zip.js +3 -0
  25. package/package.json +5 -3
  26. package/readme.hints.md +15 -1
  27. package/readme.md +43 -54
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/classes.smartarchive.ts +1 -1
  30. package/ts/classes.ziptools.ts +75 -0
  31. package/ts/index.ts +3 -0
  32. package/ts_shared/classes.ziptools.ts +690 -51
  33. package/ts_shared/errors.ts +31 -0
  34. package/ts_shared/index.ts +1 -1
  35. package/ts_shared/interfaces.ts +38 -0
  36. package/ts_shared/plugins.ziptools.ts +4 -0
  37. package/ts_web/00_commitinfo_data.ts +1 -1
  38. package/ts_web/zip.ts +7 -0
@@ -1,50 +1,488 @@
1
- import * as plugins from './plugins.js';
2
- import type { IArchiveEntry, TCompressionLevel } from './interfaces.js';
1
+ import * as plugins from './plugins.ziptools.js';
2
+ import { ZIP_ERROR_CODES, ZipError } from './errors.js';
3
+ import type {
4
+ IArchiveEntry,
5
+ IExtractedZipEntry,
6
+ ISelectiveZipExtractionOptions,
7
+ IZipEntryInfo,
8
+ TCompressionLevel,
9
+ } from './interfaces.js';
3
10
 
4
- /**
5
- * Transform stream for ZIP decompression using fflate
6
- * Emits StreamFile objects for each file in the archive
7
- */
8
- export class ZipDecompressionTransform extends plugins.smartstream.SmartDuplex<Uint8Array, plugins.smartfile.StreamFile> {
9
- private streamtools!: plugins.smartstream.IStreamTools;
10
- private unzipper = new plugins.fflate.Unzip(async (fileArg) => {
11
- let resultBuffer: Uint8Array;
12
- fileArg.ondata = async (_flateError, dat, final) => {
13
- if (resultBuffer) {
14
- const combined = new Uint8Array(resultBuffer.length + dat.length);
15
- combined.set(resultBuffer);
16
- combined.set(dat, resultBuffer.length);
17
- resultBuffer = combined;
18
- } else {
19
- resultBuffer = new Uint8Array(dat);
11
+ const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
12
+ const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
13
+ const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
14
+ const ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE = 0x07064b50;
15
+ const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
16
+ const ZIP64_EXTRA_FIELD_ID = 0x0001;
17
+ const ZIP_UNICODE_PATH_EXTRA_FIELD_ID = 0x7075;
18
+ const ZIP_ALLOWED_GENERAL_PURPOSE_FLAGS = 0x080e;
19
+ const PAKO_INPUT_CHUNK_BYTES = 16 * 1024;
20
+ const PAKO_OUTPUT_CHUNK_BYTES = 16 * 1024;
21
+
22
+ interface IParsedZipEntry {
23
+ path: string;
24
+ rawPath: Uint8Array;
25
+ flags: number;
26
+ compressionMethod: number;
27
+ crc32: number;
28
+ compressedSize: number;
29
+ uncompressedSize: number;
30
+ localHeaderOffset: number;
31
+ dataStart: number;
32
+ dataEnd: number;
33
+ localRecordEnd: number;
34
+ isDirectory: boolean;
35
+ }
36
+
37
+ interface IZipEndRecord {
38
+ offset: number;
39
+ entryCount: number;
40
+ centralDirectoryOffset: number;
41
+ centralDirectorySize: number;
42
+ }
43
+
44
+ const crc32Table = (() => {
45
+ const table = new Uint32Array(256);
46
+ for (let index = 0; index < table.length; index++) {
47
+ let value = index;
48
+ for (let bit = 0; bit < 8; bit++) {
49
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
50
+ }
51
+ table[index] = value >>> 0;
52
+ }
53
+ return table;
54
+ })();
55
+
56
+ function throwInvalidZip(message: string): never {
57
+ throw new ZipError(message, ZIP_ERROR_CODES.INVALID_ARCHIVE);
58
+ }
59
+
60
+ function throwUnsupportedZip(message: string): never {
61
+ throw new ZipError(message, ZIP_ERROR_CODES.UNSUPPORTED_FEATURE);
62
+ }
63
+
64
+ function checkedEnd(data: Uint8Array, offset: number, length: number): number {
65
+ const end = offset + length;
66
+ if (
67
+ !Number.isSafeInteger(offset) ||
68
+ !Number.isSafeInteger(length) ||
69
+ offset < 0 ||
70
+ length < 0 ||
71
+ !Number.isSafeInteger(end) ||
72
+ end > data.length
73
+ ) {
74
+ throwInvalidZip('ZIP record extends beyond the archive');
75
+ }
76
+ return end;
77
+ }
78
+
79
+ function readUint16(data: Uint8Array, offset: number): number {
80
+ checkedEnd(data, offset, 2);
81
+ return data[offset] | (data[offset + 1] << 8);
82
+ }
83
+
84
+ function readUint32(data: Uint8Array, offset: number): number {
85
+ checkedEnd(data, offset, 4);
86
+ return (
87
+ data[offset] |
88
+ (data[offset + 1] << 8) |
89
+ (data[offset + 2] << 16) |
90
+ (data[offset + 3] << 24)
91
+ ) >>> 0;
92
+ }
93
+
94
+ function equalBytes(first: Uint8Array, second: Uint8Array): boolean {
95
+ if (first.length !== second.length) return false;
96
+ for (let index = 0; index < first.length; index++) {
97
+ if (first[index] !== second[index]) return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ function validateExtraFields(data: Uint8Array, offset: number, length: number): void {
103
+ const end = checkedEnd(data, offset, length);
104
+ let cursor = offset;
105
+ while (cursor < end) {
106
+ if (cursor + 4 > end) {
107
+ throwInvalidZip('ZIP extra field header is truncated');
108
+ }
109
+ const fieldId = readUint16(data, cursor);
110
+ const fieldLength = readUint16(data, cursor + 2);
111
+ cursor += 4;
112
+ if (cursor + fieldLength > end) {
113
+ throwInvalidZip('ZIP extra field data is truncated');
114
+ }
115
+ if (fieldId === ZIP64_EXTRA_FIELD_ID) {
116
+ throwUnsupportedZip('ZIP64 entries are not supported by bounded extraction');
117
+ }
118
+ if (fieldId === ZIP_UNICODE_PATH_EXTRA_FIELD_ID) {
119
+ throwUnsupportedZip('ZIP Unicode path extra fields are not supported by bounded extraction');
120
+ }
121
+ cursor += fieldLength;
122
+ }
123
+ }
124
+
125
+ function decodeZipPath(rawPath: Uint8Array, flags: number): string {
126
+ if (!rawPath.length) {
127
+ throwInvalidZip('ZIP entry path is empty');
128
+ }
129
+
130
+ let path: string;
131
+ if (flags & 0x0800) {
132
+ try {
133
+ path = new TextDecoder('utf-8', { fatal: true }).decode(rawPath);
134
+ } catch {
135
+ throwInvalidZip('ZIP entry path is not valid UTF-8');
136
+ }
137
+ } else {
138
+ if (rawPath.some(byte => byte > 0x7f)) {
139
+ throwUnsupportedZip('ZIP entry paths without the UTF-8 flag must be ASCII');
140
+ }
141
+ path = new TextDecoder().decode(rawPath);
142
+ }
143
+
144
+ if (path.includes('\0')) {
145
+ throwInvalidZip('ZIP entry path contains a NUL byte');
146
+ }
147
+ if (path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) {
148
+ throwInvalidZip('ZIP entry path is not relative');
149
+ }
150
+
151
+ const isDirectory = path.endsWith('/');
152
+ const pathSegments = path.split('/');
153
+ if (isDirectory) pathSegments.pop();
154
+ if (
155
+ !pathSegments.length ||
156
+ pathSegments.some(segment => !segment || segment === '.' || segment === '..')
157
+ ) {
158
+ throwInvalidZip('ZIP entry path contains an invalid segment');
159
+ }
160
+
161
+ return path;
162
+ }
163
+
164
+ function findZipEndRecord(data: Uint8Array, maxEntries: number): IZipEndRecord {
165
+ if (data.length < 22) {
166
+ throwInvalidZip('ZIP end record is missing');
167
+ }
168
+
169
+ const candidates: number[] = [];
170
+ const earliestOffset = Math.max(0, data.length - 22 - 0xffff);
171
+ for (let offset = data.length - 22; offset >= earliestOffset; offset--) {
172
+ if (readUint32(data, offset) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
173
+ const commentLength = readUint16(data, offset + 20);
174
+ if (offset + 22 + commentLength === data.length) {
175
+ candidates.push(offset);
176
+ }
177
+ }
178
+
179
+ if (candidates.length !== 1) {
180
+ throwInvalidZip(
181
+ candidates.length ? 'ZIP end record is ambiguous' : 'ZIP end record is missing',
182
+ );
183
+ }
184
+
185
+ const offset = candidates[0];
186
+ const diskNumber = readUint16(data, offset + 4);
187
+ const centralDirectoryDisk = readUint16(data, offset + 6);
188
+ const entriesOnDisk = readUint16(data, offset + 8);
189
+ const entryCount = readUint16(data, offset + 10);
190
+ const centralDirectorySize = readUint32(data, offset + 12);
191
+ const centralDirectoryOffset = readUint32(data, offset + 16);
192
+
193
+ if (
194
+ diskNumber === 0xffff ||
195
+ centralDirectoryDisk === 0xffff ||
196
+ entriesOnDisk === 0xffff ||
197
+ entryCount === 0xffff ||
198
+ centralDirectorySize === 0xffffffff ||
199
+ centralDirectoryOffset === 0xffffffff
200
+ ) {
201
+ throwUnsupportedZip('ZIP64 archives are not supported by bounded extraction');
202
+ }
203
+ if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) {
204
+ throwUnsupportedZip('Split ZIP archives are not supported by bounded extraction');
205
+ }
206
+ if (
207
+ offset >= 20 &&
208
+ readUint32(data, offset - 20) === ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE
209
+ ) {
210
+ throwUnsupportedZip('ZIP64 archives are not supported by bounded extraction');
211
+ }
212
+ if (entryCount > maxEntries) {
213
+ throw new ZipError(
214
+ `ZIP contains ${entryCount} entries, exceeding the configured limit`,
215
+ ZIP_ERROR_CODES.ENTRY_COUNT_LIMIT,
216
+ );
217
+ }
218
+
219
+ const centralDirectoryEnd = checkedEnd(
220
+ data,
221
+ centralDirectoryOffset,
222
+ centralDirectorySize,
223
+ );
224
+ if (centralDirectoryEnd !== offset) {
225
+ throwInvalidZip('ZIP central directory range is inconsistent');
226
+ }
227
+
228
+ return {
229
+ offset,
230
+ entryCount,
231
+ centralDirectoryOffset,
232
+ centralDirectorySize,
233
+ };
234
+ }
235
+
236
+ function validateGeneralPurposeFlags(flags: number, compressionMethod: number): void {
237
+ if (flags & ~ZIP_ALLOWED_GENERAL_PURPOSE_FLAGS) {
238
+ throwUnsupportedZip('ZIP entry uses unsupported general-purpose flags');
239
+ }
240
+ if (compressionMethod !== 8 && flags & 0x0006) {
241
+ throwUnsupportedZip('ZIP compression flags are only supported for DEFLATE entries');
242
+ }
243
+ }
244
+
245
+ function parseDataDescriptor(
246
+ data: Uint8Array,
247
+ dataEnd: number,
248
+ centralDirectoryOffset: number,
249
+ entry: Pick<IParsedZipEntry, 'crc32' | 'compressedSize' | 'uncompressedSize'>,
250
+ ): number {
251
+ const candidates: number[] = [];
252
+
253
+ const matchesDescriptorAt = (offset: number): number | null => {
254
+ if (offset + 12 > centralDirectoryOffset) return null;
255
+ if (
256
+ readUint32(data, offset) !== entry.crc32 ||
257
+ readUint32(data, offset + 4) !== entry.compressedSize ||
258
+ readUint32(data, offset + 8) !== entry.uncompressedSize
259
+ ) {
260
+ return null;
261
+ }
262
+ return offset + 12;
263
+ };
264
+
265
+ const unsignedEnd = matchesDescriptorAt(dataEnd);
266
+ if (unsignedEnd !== null) candidates.push(unsignedEnd);
267
+
268
+ if (
269
+ dataEnd + 4 <= centralDirectoryOffset &&
270
+ readUint32(data, dataEnd) === ZIP_DATA_DESCRIPTOR_SIGNATURE
271
+ ) {
272
+ const signedEnd = matchesDescriptorAt(dataEnd + 4);
273
+ if (signedEnd !== null) candidates.push(signedEnd);
274
+ }
275
+
276
+ if (candidates.length !== 1) {
277
+ throwInvalidZip(
278
+ candidates.length
279
+ ? 'ZIP data descriptor is ambiguous'
280
+ : 'ZIP data descriptor does not match the central directory',
281
+ );
282
+ }
283
+ return candidates[0];
284
+ }
285
+
286
+ function parseZipEntries(data: Uint8Array, maxEntries: number): IParsedZipEntry[] {
287
+ const endRecord = findZipEndRecord(data, maxEntries);
288
+ const centralDirectoryEnd =
289
+ endRecord.centralDirectoryOffset + endRecord.centralDirectorySize;
290
+ const entries: IParsedZipEntry[] = [];
291
+ const localOffsets = new Set<number>();
292
+ let cursor = endRecord.centralDirectoryOffset;
293
+
294
+ for (let entryIndex = 0; entryIndex < endRecord.entryCount; entryIndex++) {
295
+ if (readUint32(data, cursor) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
296
+ throwInvalidZip('ZIP central directory entry signature is invalid');
297
+ }
298
+ checkedEnd(data, cursor, 46);
299
+
300
+ const versionNeeded = readUint16(data, cursor + 6);
301
+ const flags = readUint16(data, cursor + 8);
302
+ const compressionMethod = readUint16(data, cursor + 10);
303
+ const modificationTime = readUint16(data, cursor + 12);
304
+ const modificationDate = readUint16(data, cursor + 14);
305
+ const crc32 = readUint32(data, cursor + 16);
306
+ const compressedSize = readUint32(data, cursor + 20);
307
+ const uncompressedSize = readUint32(data, cursor + 24);
308
+ const pathLength = readUint16(data, cursor + 28);
309
+ const extraLength = readUint16(data, cursor + 30);
310
+ const commentLength = readUint16(data, cursor + 32);
311
+ const diskNumberStart = readUint16(data, cursor + 34);
312
+ const localHeaderOffset = readUint32(data, cursor + 42);
313
+
314
+ if (
315
+ compressedSize === 0xffffffff ||
316
+ uncompressedSize === 0xffffffff ||
317
+ diskNumberStart === 0xffff ||
318
+ localHeaderOffset === 0xffffffff
319
+ ) {
320
+ throwUnsupportedZip('ZIP64 entries are not supported by bounded extraction');
321
+ }
322
+ if (diskNumberStart !== 0) {
323
+ throwUnsupportedZip('Split ZIP entries are not supported by bounded extraction');
324
+ }
325
+ validateGeneralPurposeFlags(flags, compressionMethod);
326
+ if (compressionMethod === 0 || compressionMethod === 8) {
327
+ const minimumVersion = compressionMethod === 8 || (flags & 0x0008) !== 0 ? 20 : 10;
328
+ if (versionNeeded < minimumVersion) {
329
+ throwInvalidZip('ZIP entry declares an insufficient extraction version');
20
330
  }
21
- if (final) {
22
- const streamFile = plugins.smartfile.StreamFile.fromBuffer(Buffer.from(resultBuffer));
23
- streamFile.relativeFilePath = fileArg.name;
24
- this.streamtools.push(streamFile);
331
+ if (versionNeeded > 20) {
332
+ throwUnsupportedZip('ZIP entry requires features outside the bounded subset');
25
333
  }
26
- };
27
- fileArg.start();
28
- });
29
-
30
- constructor() {
31
- super({
32
- objectMode: true,
33
- writeFunction: async (chunkArg, streamtoolsArg) => {
34
- this.streamtools ? null : (this.streamtools = streamtoolsArg);
35
- const chunk = chunkArg instanceof Uint8Array ? chunkArg : new Uint8Array(chunkArg);
36
- this.unzipper.push(chunk, false);
37
- return null;
38
- },
39
- finalFunction: async () => {
40
- this.unzipper.push(new Uint8Array(0), true);
41
- await plugins.smartdelay.delayFor(0);
42
- await this.streamtools.push(null);
43
- return null;
44
- },
334
+ }
335
+ if (compressionMethod === 0 && compressedSize !== uncompressedSize) {
336
+ throwInvalidZip('ZIP stored entry has inconsistent sizes');
337
+ }
338
+
339
+ const pathOffset = cursor + 46;
340
+ const extraOffset = checkedEnd(data, pathOffset, pathLength);
341
+ const commentOffset = checkedEnd(data, extraOffset, extraLength);
342
+ const nextCursor = checkedEnd(data, commentOffset, commentLength);
343
+ if (nextCursor > centralDirectoryEnd) {
344
+ throwInvalidZip('ZIP central directory entry exceeds the central directory');
345
+ }
346
+
347
+ const rawPath = data.subarray(pathOffset, extraOffset);
348
+ const path = decodeZipPath(rawPath, flags);
349
+ validateExtraFields(data, extraOffset, extraLength);
350
+
351
+ if (localOffsets.has(localHeaderOffset)) {
352
+ throwInvalidZip('ZIP entries reference the same local header');
353
+ }
354
+ localOffsets.add(localHeaderOffset);
355
+ if (localHeaderOffset >= endRecord.centralDirectoryOffset) {
356
+ throwInvalidZip('ZIP local header intersects the central directory');
357
+ }
358
+ if (readUint32(data, localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
359
+ throwInvalidZip('ZIP local file header signature is invalid');
360
+ }
361
+ checkedEnd(data, localHeaderOffset, 30);
362
+
363
+ const localVersionNeeded = readUint16(data, localHeaderOffset + 4);
364
+ const localFlags = readUint16(data, localHeaderOffset + 6);
365
+ const localCompressionMethod = readUint16(data, localHeaderOffset + 8);
366
+ const localModificationTime = readUint16(data, localHeaderOffset + 10);
367
+ const localModificationDate = readUint16(data, localHeaderOffset + 12);
368
+ const localCrc32 = readUint32(data, localHeaderOffset + 14);
369
+ const localCompressedSize = readUint32(data, localHeaderOffset + 18);
370
+ const localUncompressedSize = readUint32(data, localHeaderOffset + 22);
371
+ const localPathLength = readUint16(data, localHeaderOffset + 26);
372
+ const localExtraLength = readUint16(data, localHeaderOffset + 28);
373
+ const localPathOffset = localHeaderOffset + 30;
374
+ const localExtraOffset = checkedEnd(data, localPathOffset, localPathLength);
375
+ const dataStart = checkedEnd(data, localExtraOffset, localExtraLength);
376
+ const dataEnd = checkedEnd(data, dataStart, compressedSize);
377
+
378
+ if (dataEnd > endRecord.centralDirectoryOffset) {
379
+ throwInvalidZip('ZIP local file data intersects the central directory');
380
+ }
381
+ if (
382
+ localVersionNeeded !== versionNeeded ||
383
+ localFlags !== flags ||
384
+ localCompressionMethod !== compressionMethod ||
385
+ localModificationTime !== modificationTime ||
386
+ localModificationDate !== modificationDate
387
+ ) {
388
+ throwInvalidZip('ZIP local and central entry metadata differ');
389
+ }
390
+ const localRawPath = data.subarray(localPathOffset, localExtraOffset);
391
+ if (!equalBytes(localRawPath, rawPath)) {
392
+ throwInvalidZip('ZIP local and central entry paths differ');
393
+ }
394
+ validateExtraFields(data, localExtraOffset, localExtraLength);
395
+
396
+ let localRecordEnd = dataEnd;
397
+ if (flags & 0x0008) {
398
+ if (localCrc32 !== 0 || localCompressedSize !== 0 || localUncompressedSize !== 0) {
399
+ throwInvalidZip('ZIP data-descriptor entry has non-zero local size metadata');
400
+ }
401
+ localRecordEnd = parseDataDescriptor(data, dataEnd, endRecord.centralDirectoryOffset, {
402
+ crc32,
403
+ compressedSize,
404
+ uncompressedSize,
405
+ });
406
+ } else if (
407
+ localCrc32 !== crc32 ||
408
+ localCompressedSize !== compressedSize ||
409
+ localUncompressedSize !== uncompressedSize
410
+ ) {
411
+ throwInvalidZip('ZIP local sizes or CRC differ from the central directory');
412
+ }
413
+
414
+ entries.push({
415
+ path,
416
+ rawPath,
417
+ flags,
418
+ compressionMethod,
419
+ crc32,
420
+ compressedSize,
421
+ uncompressedSize,
422
+ localHeaderOffset,
423
+ dataStart,
424
+ dataEnd,
425
+ localRecordEnd,
426
+ isDirectory: path.endsWith('/'),
45
427
  });
46
- this.unzipper.register(plugins.fflate.UnzipInflate);
428
+ cursor = nextCursor;
429
+ }
430
+
431
+ if (cursor !== centralDirectoryEnd) {
432
+ throwUnsupportedZip('ZIP central directory signatures are not supported by bounded extraction');
433
+ }
434
+
435
+ const entriesByLocalOffset = [...entries].sort(
436
+ (first, second) => first.localHeaderOffset - second.localHeaderOffset,
437
+ );
438
+ for (let index = 1; index < entriesByLocalOffset.length; index++) {
439
+ if (
440
+ entriesByLocalOffset[index - 1].localRecordEnd >
441
+ entriesByLocalOffset[index].localHeaderOffset
442
+ ) {
443
+ throwInvalidZip('ZIP local file records overlap');
444
+ }
445
+ }
446
+
447
+ return entries;
448
+ }
449
+
450
+ function validateSelectiveOptions(options: ISelectiveZipExtractionOptions): void {
451
+ if (!options || typeof options.filter !== 'function') {
452
+ throw new ZipError('ZIP extraction filter must be a function', ZIP_ERROR_CODES.INVALID_LIMIT);
453
+ }
454
+ const limits = [
455
+ options.maxArchiveBytes,
456
+ options.maxEntries,
457
+ options.maxSelectedEntries,
458
+ options.maxCompressedEntryBytes,
459
+ options.maxUncompressedEntryBytes,
460
+ options.maxTotalUncompressedBytes,
461
+ ];
462
+ if (limits.some(limit => !Number.isSafeInteger(limit) || limit < 1)) {
463
+ throw new ZipError(
464
+ 'ZIP extraction limits must be positive safe integers',
465
+ ZIP_ERROR_CODES.INVALID_LIMIT,
466
+ );
467
+ }
468
+ }
469
+
470
+ function updateCrc32(crc: number, data: Uint8Array): number {
471
+ let nextCrc = crc;
472
+ for (const byte of data) {
473
+ nextCrc = crc32Table[(nextCrc ^ byte) & 0xff] ^ (nextCrc >>> 8);
474
+ }
475
+ return nextCrc >>> 0;
476
+ }
477
+
478
+ function combineChunks(chunks: Uint8Array[], byteLength: number): Uint8Array {
479
+ const result = new Uint8Array(byteLength);
480
+ let offset = 0;
481
+ for (const chunk of chunks) {
482
+ result.set(chunk, offset);
483
+ offset += chunk.length;
47
484
  }
485
+ return result;
48
486
  }
49
487
 
50
488
  /**
@@ -52,10 +490,216 @@ export class ZipDecompressionTransform extends plugins.smartstream.SmartDuplex<U
52
490
  */
53
491
  export class ZipTools {
54
492
  /**
55
- * Get a streaming decompression transform for extracting ZIP archives
493
+ * Extract selected entries from a strict, bounded ZIP subset.
494
+ *
495
+ * This security-focused API accepts single-disk, non-ZIP64 archives and
496
+ * decompresses only entries selected from validated central-directory metadata.
56
497
  */
57
- public getDecompressionStream(): ZipDecompressionTransform {
58
- return new ZipDecompressionTransform();
498
+ public async extractSelectedZipEntries(
499
+ data: Uint8Array,
500
+ options: ISelectiveZipExtractionOptions,
501
+ ): Promise<IExtractedZipEntry[]> {
502
+ validateSelectiveOptions(options);
503
+ if (!(data instanceof Uint8Array)) {
504
+ throw new ZipError('ZIP archive must be a Uint8Array', ZIP_ERROR_CODES.INVALID_ARCHIVE);
505
+ }
506
+ if (data.length > options.maxArchiveBytes) {
507
+ throw new ZipError(
508
+ `ZIP archive has ${data.length} bytes, exceeding the configured limit`,
509
+ ZIP_ERROR_CODES.ARCHIVE_SIZE_LIMIT,
510
+ );
511
+ }
512
+
513
+ const entries = parseZipEntries(data, options.maxEntries);
514
+ const selectedEntries: IParsedZipEntry[] = [];
515
+ const selectedPaths = new Set<string>();
516
+ let declaredTotalUncompressedBytes = 0;
517
+
518
+ for (const entry of entries) {
519
+ const entryInfo: IZipEntryInfo = {
520
+ path: entry.path,
521
+ size: entry.uncompressedSize,
522
+ compressedSize: entry.compressedSize,
523
+ compressionMethod: entry.compressionMethod,
524
+ isDirectory: entry.isDirectory,
525
+ isFile: !entry.isDirectory,
526
+ };
527
+ if (!options.filter(entryInfo)) continue;
528
+
529
+ if (selectedPaths.has(entry.path)) {
530
+ throw new ZipError(
531
+ `ZIP contains duplicate selected path: ${entry.path}`,
532
+ ZIP_ERROR_CODES.DUPLICATE_SELECTED_PATH,
533
+ );
534
+ }
535
+ selectedPaths.add(entry.path);
536
+ if (selectedEntries.length >= options.maxSelectedEntries) {
537
+ throw new ZipError(
538
+ 'ZIP selected entry count exceeds the configured limit',
539
+ ZIP_ERROR_CODES.SELECTED_ENTRY_COUNT_LIMIT,
540
+ );
541
+ }
542
+ if (entry.compressedSize > options.maxCompressedEntryBytes) {
543
+ throw new ZipError(
544
+ `ZIP selected entry ${entry.path} exceeds the compressed-size limit`,
545
+ ZIP_ERROR_CODES.COMPRESSED_SIZE_LIMIT,
546
+ );
547
+ }
548
+ if (entry.uncompressedSize > options.maxUncompressedEntryBytes) {
549
+ throw new ZipError(
550
+ `ZIP selected entry ${entry.path} exceeds the uncompressed-size limit`,
551
+ ZIP_ERROR_CODES.UNCOMPRESSED_SIZE_LIMIT,
552
+ );
553
+ }
554
+ if (
555
+ entry.uncompressedSize >
556
+ options.maxTotalUncompressedBytes - declaredTotalUncompressedBytes
557
+ ) {
558
+ throw new ZipError(
559
+ 'ZIP selected entries exceed the total uncompressed-size limit',
560
+ ZIP_ERROR_CODES.TOTAL_UNCOMPRESSED_SIZE_LIMIT,
561
+ );
562
+ }
563
+ if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
564
+ throw new ZipError(
565
+ `ZIP selected entry ${entry.path} uses unsupported compression method ${entry.compressionMethod}`,
566
+ ZIP_ERROR_CODES.UNSUPPORTED_FEATURE,
567
+ );
568
+ }
569
+
570
+ declaredTotalUncompressedBytes += entry.uncompressedSize;
571
+ selectedEntries.push(entry);
572
+ }
573
+
574
+ const extractedEntries: IExtractedZipEntry[] = [];
575
+ let actualTotalUncompressedBytes = 0;
576
+
577
+ for (const entry of selectedEntries) {
578
+ const compressedData = data.subarray(entry.dataStart, entry.dataEnd);
579
+ let content: Uint8Array;
580
+ let crcState = 0xffffffff;
581
+
582
+ if (entry.compressionMethod === 0) {
583
+ if (entry.compressedSize !== entry.uncompressedSize) {
584
+ throw new ZipError(
585
+ `ZIP stored entry ${entry.path} has inconsistent sizes`,
586
+ ZIP_ERROR_CODES.UNCOMPRESSED_SIZE_MISMATCH,
587
+ );
588
+ }
589
+ if (
590
+ entry.uncompressedSize >
591
+ options.maxTotalUncompressedBytes - actualTotalUncompressedBytes
592
+ ) {
593
+ throw new ZipError(
594
+ 'ZIP selected entries exceed the total uncompressed-size limit',
595
+ ZIP_ERROR_CODES.TOTAL_UNCOMPRESSED_SIZE_LIMIT,
596
+ );
597
+ }
598
+ content = new Uint8Array(compressedData);
599
+ crcState = updateCrc32(crcState, content);
600
+ actualTotalUncompressedBytes += content.length;
601
+ } else {
602
+ const chunks: Uint8Array[] = [];
603
+ let actualEntryBytes = 0;
604
+ let zstream: plugins.pako.ZStream | undefined;
605
+ const inflater = new plugins.pako.Inflate({
606
+ raw: true,
607
+ chunkSize: PAKO_OUTPUT_CHUNK_BYTES,
608
+ });
609
+ inflater.onStart = (stream) => {
610
+ zstream = stream;
611
+ };
612
+ inflater.onData = (chunk) => {
613
+ if (chunk.length > entry.uncompressedSize - actualEntryBytes) {
614
+ throw new ZipError(
615
+ `ZIP selected entry ${entry.path} expands beyond its declared size`,
616
+ ZIP_ERROR_CODES.UNCOMPRESSED_SIZE_MISMATCH,
617
+ );
618
+ }
619
+ if (chunk.length > options.maxUncompressedEntryBytes - actualEntryBytes) {
620
+ throw new ZipError(
621
+ `ZIP selected entry ${entry.path} exceeds the uncompressed-size limit`,
622
+ ZIP_ERROR_CODES.UNCOMPRESSED_SIZE_LIMIT,
623
+ );
624
+ }
625
+ if (
626
+ chunk.length >
627
+ options.maxTotalUncompressedBytes - actualTotalUncompressedBytes
628
+ ) {
629
+ throw new ZipError(
630
+ 'ZIP selected entries exceed the total uncompressed-size limit',
631
+ ZIP_ERROR_CODES.TOTAL_UNCOMPRESSED_SIZE_LIMIT,
632
+ );
633
+ }
634
+
635
+ const retainedChunk = chunk.slice();
636
+ chunks.push(retainedChunk);
637
+ crcState = updateCrc32(crcState, retainedChunk);
638
+ actualEntryBytes += retainedChunk.length;
639
+ actualTotalUncompressedBytes += retainedChunk.length;
640
+ };
641
+
642
+ let pushSucceeded = true;
643
+ let inputOffset = 0;
644
+ try {
645
+ do {
646
+ const inputEnd = Math.min(
647
+ inputOffset + PAKO_INPUT_CHUNK_BYTES,
648
+ compressedData.length,
649
+ );
650
+ pushSucceeded = inflater.push(
651
+ compressedData.subarray(inputOffset, inputEnd),
652
+ inputEnd === compressedData.length,
653
+ );
654
+ inputOffset = inputEnd;
655
+ if (!pushSucceeded) break;
656
+ if (inflater.ended && inputOffset < compressedData.length) {
657
+ throw new ZipError(
658
+ `ZIP selected entry ${entry.path} ended before consuming its compressed data`,
659
+ ZIP_ERROR_CODES.COMPRESSED_SIZE_MISMATCH,
660
+ );
661
+ }
662
+ } while (inputOffset < compressedData.length);
663
+ } catch (error) {
664
+ if (error instanceof ZipError) throw error;
665
+ throw new ZipError(
666
+ `ZIP selected entry ${entry.path} contains invalid DEFLATE data`,
667
+ ZIP_ERROR_CODES.INVALID_ARCHIVE,
668
+ );
669
+ }
670
+
671
+ if (!pushSucceeded || inflater.err || !inflater.ended || !zstream) {
672
+ throw new ZipError(
673
+ `ZIP selected entry ${entry.path} contains invalid or truncated DEFLATE data`,
674
+ ZIP_ERROR_CODES.INVALID_ARCHIVE,
675
+ );
676
+ }
677
+ if (zstream.avail_in !== 0 || zstream.total_in !== compressedData.length) {
678
+ throw new ZipError(
679
+ `ZIP selected entry ${entry.path} did not consume its exact compressed data`,
680
+ ZIP_ERROR_CODES.COMPRESSED_SIZE_MISMATCH,
681
+ );
682
+ }
683
+ if (actualEntryBytes !== entry.uncompressedSize) {
684
+ throw new ZipError(
685
+ `ZIP selected entry ${entry.path} did not produce its declared size`,
686
+ ZIP_ERROR_CODES.UNCOMPRESSED_SIZE_MISMATCH,
687
+ );
688
+ }
689
+ content = combineChunks(chunks, actualEntryBytes);
690
+ }
691
+
692
+ const actualCrc32 = (crcState ^ 0xffffffff) >>> 0;
693
+ if (actualCrc32 !== entry.crc32) {
694
+ throw new ZipError(
695
+ `ZIP selected entry ${entry.path} failed its CRC check`,
696
+ ZIP_ERROR_CODES.CRC_MISMATCH,
697
+ );
698
+ }
699
+ extractedEntries.push({ path: entry.path, content });
700
+ }
701
+
702
+ return extractedEntries;
59
703
  }
60
704
 
61
705
  /**
@@ -71,13 +715,8 @@ export class ZipTools {
71
715
  data = new TextEncoder().encode(entry.content);
72
716
  } else if (entry.content instanceof Uint8Array) {
73
717
  data = entry.content;
74
- } else if (entry.content instanceof plugins.smartfile.SmartFile) {
75
- data = new Uint8Array(entry.content.contents);
76
- } else if (entry.content instanceof plugins.smartfile.StreamFile) {
77
- const buffer = await entry.content.getContentAsBuffer();
78
- data = new Uint8Array(buffer);
79
718
  } else {
80
- throw new Error('Unsupported content type for ZIP entry');
719
+ throw new Error('Shared ZIP creation accepts string or Uint8Array content');
81
720
  }
82
721
 
83
722
  if (compressionLevel !== undefined) {