@naturalcycles/nodejs-lib 15.109.0 → 15.111.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.
@@ -0,0 +1,620 @@
1
+ /*
2
+
3
+ A minimal, dependency-free zip archive reader for Node.js 24+.
4
+
5
+ Adapted from yauzl (https://github.com/thejoshwolfe/yauzl) by Josh Wolfe (MIT License),
6
+ rewritten in a modern, Promise-based, async/await style:
7
+
8
+ - No external dependencies. `buffer-crc32` is replaced by the built-in `zlib.crc32`,
9
+ `fd-slicer`/`pend` are replaced by `node:fs/promises` FileHandle reads.
10
+ - Promise API instead of callbacks/EventEmitter (inspired by yauzl PR #171).
11
+ - The central directory is parsed eagerly into `ZipReader.entries`.
12
+ - Scope is intentionally small: read/extract `stored` (0) and `deflate` (8) entries.
13
+ Encryption and other compression methods are detected and rejected, not implemented.
14
+
15
+ Robustness kept from yauzl: backwards EOCD search, ZIP64 reading, bounds checks,
16
+ file name decoding (UTF-8, cp437, Info-ZIP Unicode Path field) and path-traversal
17
+ validation.
18
+
19
+ */
20
+
21
+ import { isUtf8 } from 'node:buffer'
22
+ import { createReadStream, createWriteStream } from 'node:fs'
23
+ import fsp from 'node:fs/promises'
24
+ import type { FileHandle } from 'node:fs/promises'
25
+ import path from 'node:path'
26
+ import { Readable } from 'node:stream'
27
+ import { pipeline } from 'node:stream/promises'
28
+ import { promisify } from 'node:util'
29
+ import zlib from 'node:zlib'
30
+ import type { UnixTimestamp } from '@naturalcycles/js-lib/types'
31
+ import {
32
+ assertSafeZipEntryName,
33
+ CDFH_SIG,
34
+ CDFH_SIZE,
35
+ DEFLATE,
36
+ dosDateTimeToUnix,
37
+ EOCDR_SIG,
38
+ EOCDR_SIZE,
39
+ LOCAL_FILE_HEADER_SIG,
40
+ MAX_COMMENT_SIZE,
41
+ normalizeZipEntryName,
42
+ readUInt64LE,
43
+ STORED,
44
+ ZIP64_EOCDL_SIG,
45
+ ZIP64_EOCDL_SIZE,
46
+ ZIP64_EOCDR_SIG,
47
+ ZIP64_EOCDR_SIZE,
48
+ } from './zipInternal.js'
49
+
50
+ // oxlint-disable no-bitwise -- parsing the binary ZIP format requires bitwise ops on bit flags and packed DOS date/time fields
51
+
52
+ const inflateRawAsync = promisify(zlib.inflateRaw.bind(zlib))
53
+
54
+ /**
55
+ * Open a zip archive from a file on disk.
56
+ *
57
+ * Reads and parses the central directory; the returned {@link ZipReader} exposes
58
+ * the list of entries and lets you read their contents.
59
+ *
60
+ * Remember to call {@link ZipReader.close} when done, or use {@link extractZip}.
61
+ */
62
+ export async function openZip(filePath: string): Promise<ZipReader> {
63
+ const fileHandle = await fsp.open(filePath, 'r')
64
+ let source: FileSource
65
+ try {
66
+ const { size } = await fileHandle.stat()
67
+ source = new FileSource(fileHandle, filePath, size)
68
+ } catch (err) {
69
+ await fileHandle.close()
70
+ throw err
71
+ }
72
+ return await readArchive(source)
73
+ }
74
+
75
+ /**
76
+ * Open a zip archive from an in-memory Buffer.
77
+ */
78
+ export async function openZipBuffer(buffer: Buffer): Promise<ZipReader> {
79
+ return await readArchive(new BufferSource(buffer))
80
+ }
81
+
82
+ /**
83
+ * Reads entries from an open zip archive.
84
+ *
85
+ * Create one via {@link openZip} or {@link openZipBuffer}.
86
+ *
87
+ * Implements `AsyncDisposable`, so it can be used with `await using` to close
88
+ * automatically on scope exit:
89
+ *
90
+ * ```ts
91
+ * await using zip = await openZip('archive.zip')
92
+ * const buf = await zip.readEntry(zip.entries[0])
93
+ * // zip.close() is called automatically here
94
+ * ```
95
+ */
96
+ export class ZipReader implements AsyncDisposable {
97
+ constructor(
98
+ private source: ZipSource,
99
+ /**
100
+ * All entries (files and directories) found in the archive,
101
+ * in central-directory order.
102
+ */
103
+ readonly entries: ZipEntry[],
104
+ /**
105
+ * Archive-level comment (empty string if none).
106
+ */
107
+ readonly comment: string,
108
+ ) {}
109
+
110
+ /**
111
+ * Read and fully decompress an entry into a Buffer.
112
+ *
113
+ * Validates the uncompressed size and CRC-32 checksum.
114
+ * For large entries prefer {@link openReadStream}.
115
+ */
116
+ async readEntry(entry: ZipEntry): Promise<Buffer> {
117
+ this.assertReadable(entry)
118
+ const fileDataStart = await this.findFileDataStart(entry)
119
+ const raw = await this.source.read(fileDataStart, entry.compressedSize)
120
+ const data = entry.compressionMethod === STORED ? raw : await inflateRawAsync(raw)
121
+
122
+ if (data.length !== entry.uncompressedSize) {
123
+ throw new Error(
124
+ `uncompressed size mismatch for ${entry.fileName}: expected ${entry.uncompressedSize}, got ${data.length}`,
125
+ )
126
+ }
127
+ const actualCrc = zlib.crc32(data)
128
+ if (actualCrc !== entry.crc32) {
129
+ throw new Error(
130
+ `crc32 mismatch for ${entry.fileName}: expected ${entry.crc32}, got ${actualCrc}`,
131
+ )
132
+ }
133
+ return data
134
+ }
135
+
136
+ /**
137
+ * Open a Readable stream of an entry's decompressed contents.
138
+ *
139
+ * Useful for piping large entries to disk without buffering them in memory.
140
+ * Unlike {@link readEntry}, this does not verify the CRC-32 checksum.
141
+ */
142
+ async openReadStream(entry: ZipEntry): Promise<Readable> {
143
+ this.assertReadable(entry)
144
+ const fileDataStart = await this.findFileDataStart(entry)
145
+ const raw = this.source.createReadStream(fileDataStart, fileDataStart + entry.compressedSize)
146
+ if (entry.compressionMethod === STORED) return raw
147
+
148
+ const inflate = zlib.createInflateRaw()
149
+ // Forward read errors into the decompression stream, and tear down the source
150
+ // stream if the consumer abandons the decompression stream early.
151
+ raw.once('error', err => inflate.destroy(err))
152
+ inflate.once('close', () => {
153
+ if (!raw.destroyed) raw.destroy()
154
+ })
155
+ raw.pipe(inflate)
156
+ return inflate
157
+ }
158
+
159
+ /**
160
+ * Extract all entries into `destDir`, streaming each file to disk.
161
+ * See {@link extractZip}.
162
+ */
163
+ async extractAll(destDir: string): Promise<ZipEntry[]> {
164
+ const root = path.resolve(destDir)
165
+ for (const entry of this.entries) {
166
+ const targetPath = assertPathInside(root, entry.fileName)
167
+ if (entry.isDirectory) {
168
+ await fsp.mkdir(targetPath, { recursive: true })
169
+ continue
170
+ }
171
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true })
172
+ const readStream = await this.openReadStream(entry)
173
+ await pipeline(readStream, createWriteStream(targetPath))
174
+ }
175
+ return this.entries
176
+ }
177
+
178
+ /**
179
+ * Close the underlying file handle. No-op for buffer-backed archives.
180
+ * Safe to call multiple times.
181
+ */
182
+ async close(): Promise<void> {
183
+ await this.source.close()
184
+ }
185
+
186
+ /**
187
+ * Called by `await using`; closes the archive. See {@link close}.
188
+ */
189
+ async [Symbol.asyncDispose](): Promise<void> {
190
+ await this.close()
191
+ }
192
+
193
+ private assertReadable(entry: ZipEntry): void {
194
+ if (entry.isEncrypted) {
195
+ throw new Error(`encrypted entries are not supported: ${entry.fileName}`)
196
+ }
197
+ if (entry.compressionMethod !== STORED && entry.compressionMethod !== DEFLATE) {
198
+ throw new Error(
199
+ `unsupported compression method ${entry.compressionMethod} for ${entry.fileName}`,
200
+ )
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Read the local file header to locate the start of the entry's data.
206
+ * The local header's name/extra-field lengths can differ from the central
207
+ * directory's, so this must be read per entry.
208
+ */
209
+ private async findFileDataStart(entry: ZipEntry): Promise<number> {
210
+ const header = await this.source.read(entry.relativeOffsetOfLocalHeader, 30)
211
+ const signature = header.readUInt32LE(0)
212
+ if (signature !== LOCAL_FILE_HEADER_SIG) {
213
+ throw new Error(`invalid local file header signature: 0x${signature.toString(16)}`)
214
+ }
215
+ const fileNameLength = header.readUInt16LE(26)
216
+ const extraFieldLength = header.readUInt16LE(28)
217
+ const fileDataStart = entry.relativeOffsetOfLocalHeader + 30 + fileNameLength + extraFieldLength
218
+ if (fileDataStart + entry.compressedSize > this.source.size) {
219
+ throw new Error(`file data overflows archive bounds for ${entry.fileName}`)
220
+ }
221
+ return fileDataStart
222
+ }
223
+ }
224
+
225
+ async function readArchive(source: ZipSource): Promise<ZipReader> {
226
+ try {
227
+ const eocd = await readEndOfCentralDirectory(source)
228
+ const cdSize = eocd.centralDirectoryEnd - eocd.centralDirectoryOffset
229
+ if (eocd.centralDirectoryOffset < 0 || cdSize < 0 || eocd.centralDirectoryEnd > source.size) {
230
+ throw new Error('invalid central directory location')
231
+ }
232
+ const centralDirectory = await source.read(eocd.centralDirectoryOffset, cdSize)
233
+ const entries = parseCentralDirectory(centralDirectory, eocd.entryCount)
234
+ return new ZipReader(source, entries, eocd.comment)
235
+ } catch (err) {
236
+ await source.close()
237
+ throw err
238
+ }
239
+ }
240
+
241
+ interface EndOfCentralDirectory {
242
+ entryCount: number
243
+ centralDirectoryOffset: number
244
+ /** Byte offset where the central directory region ends (start of the EOCD / ZIP64 EOCD). */
245
+ centralDirectoryEnd: number
246
+ comment: string
247
+ }
248
+
249
+ /**
250
+ * The End of Central Directory (EOCD) record sits at the very end of the file,
251
+ * followed only by a variable-length comment, so we search backwards for its
252
+ * signature. A ZIP64 EOCD locator may precede it for large archives.
253
+ */
254
+ async function readEndOfCentralDirectory(source: ZipSource): Promise<EndOfCentralDirectory> {
255
+ const { size } = source
256
+ if (size < EOCDR_SIZE) {
257
+ throw new Error('not a zip file: file is too small')
258
+ }
259
+ const searchLength = Math.min(EOCDR_SIZE + MAX_COMMENT_SIZE + ZIP64_EOCDL_SIZE, size)
260
+ const searchStart = size - searchLength
261
+ const buf = await source.read(searchStart, searchLength)
262
+
263
+ for (let i = buf.length - EOCDR_SIZE; i >= 0; i--) {
264
+ if (buf.readUInt32LE(i) !== EOCDR_SIG) continue
265
+ const eocdr = buf.subarray(i)
266
+ const diskNumber = eocdr.readUInt16LE(4)
267
+ let entryCount = eocdr.readUInt16LE(10)
268
+ let centralDirectoryOffset = eocdr.readUInt32LE(16)
269
+ const commentLength = eocdr.readUInt16LE(20)
270
+ const expectedCommentLength = eocdr.length - EOCDR_SIZE
271
+ if (commentLength !== expectedCommentLength) {
272
+ throw new Error(
273
+ `invalid comment length: expected ${expectedCommentLength}, found ${commentLength}`,
274
+ )
275
+ }
276
+ // The EOCD comment is always cp437-encoded.
277
+ const comment = decodeBuffer(eocdr.subarray(EOCDR_SIZE), false)
278
+ // The central directory region ends where this EOCD record begins.
279
+ let centralDirectoryEnd = searchStart + i
280
+
281
+ // A ZIP64 End of Central Directory Locator sits immediately before the EOCD.
282
+ const locatorIndex = i - ZIP64_EOCDL_SIZE
283
+ if (locatorIndex >= 0 && buf.readUInt32LE(locatorIndex) === ZIP64_EOCDL_SIG) {
284
+ const zip64EocdrOffset = readUInt64LE(buf, locatorIndex + 8)
285
+ const zip64 = await source.read(zip64EocdrOffset, ZIP64_EOCDR_SIZE)
286
+ if (zip64.readUInt32LE(0) !== ZIP64_EOCDR_SIG) {
287
+ throw new Error('invalid zip64 end of central directory record signature')
288
+ }
289
+ if (zip64.readUInt32LE(16) !== 0) {
290
+ throw new Error('multi-disk zip files are not supported')
291
+ }
292
+ entryCount = readUInt64LE(zip64, 32)
293
+ centralDirectoryOffset = readUInt64LE(zip64, 48)
294
+ centralDirectoryEnd = zip64EocdrOffset
295
+ } else if (diskNumber !== 0) {
296
+ throw new Error('multi-disk zip files are not supported')
297
+ }
298
+
299
+ return { entryCount, centralDirectoryOffset, centralDirectoryEnd, comment }
300
+ }
301
+
302
+ throw new Error('end of central directory record not found: not a zip file, or it is truncated')
303
+ }
304
+
305
+ function parseCentralDirectory(buf: Buffer, entryCount: number): ZipEntry[] {
306
+ const entries: ZipEntry[] = []
307
+ let cursor = 0
308
+ for (let n = 0; n < entryCount; n++) {
309
+ if (cursor + CDFH_SIZE > buf.length) {
310
+ throw new Error('central directory is truncated')
311
+ }
312
+ const signature = buf.readUInt32LE(cursor)
313
+ if (signature !== CDFH_SIG) {
314
+ throw new Error(
315
+ `invalid central directory file header signature: 0x${signature.toString(16)}`,
316
+ )
317
+ }
318
+ const generalPurposeBitFlag = buf.readUInt16LE(cursor + 8)
319
+ const compressionMethod = buf.readUInt16LE(cursor + 10)
320
+ const lastModFileTime = buf.readUInt16LE(cursor + 12)
321
+ const lastModFileDate = buf.readUInt16LE(cursor + 14)
322
+ const crc32 = buf.readUInt32LE(cursor + 16)
323
+ let compressedSize = buf.readUInt32LE(cursor + 20)
324
+ let uncompressedSize = buf.readUInt32LE(cursor + 24)
325
+ const fileNameLength = buf.readUInt16LE(cursor + 28)
326
+ const extraFieldLength = buf.readUInt16LE(cursor + 30)
327
+ const fileCommentLength = buf.readUInt16LE(cursor + 32)
328
+ let relativeOffsetOfLocalHeader = buf.readUInt32LE(cursor + 42)
329
+
330
+ if (generalPurposeBitFlag & 0x40) {
331
+ throw new Error('strong encryption is not supported')
332
+ }
333
+
334
+ const nameStart = cursor + CDFH_SIZE
335
+ const extraStart = nameStart + fileNameLength
336
+ const commentStart = extraStart + extraFieldLength
337
+ const entryEnd = commentStart + fileCommentLength
338
+ if (entryEnd > buf.length) {
339
+ throw new Error('central directory entry overflows the central directory')
340
+ }
341
+
342
+ const fileNameRaw = buf.subarray(nameStart, extraStart)
343
+ const extraFieldRaw = buf.subarray(extraStart, commentStart)
344
+ const fileCommentRaw = buf.subarray(commentStart, entryEnd)
345
+ const extraFields = parseExtraFields(extraFieldRaw)
346
+
347
+ // ZIP64: when a 32-bit field holds the 0xffffffff sentinel, the real value
348
+ // lives in the 0x0001 extra field.
349
+ const zip64 = readZip64ExtraField(
350
+ extraFields,
351
+ uncompressedSize,
352
+ compressedSize,
353
+ relativeOffsetOfLocalHeader,
354
+ )
355
+ uncompressedSize = zip64.uncompressedSize
356
+ compressedSize = zip64.compressedSize
357
+ relativeOffsetOfLocalHeader = zip64.relativeOffsetOfLocalHeader
358
+
359
+ const hasUtf8Flag = (generalPurposeBitFlag & 0x800) !== 0
360
+ const fileName = decodeFileName(generalPurposeBitFlag, fileNameRaw, extraFields)
361
+ assertSafeZipEntryName(fileName)
362
+
363
+ entries.push({
364
+ fileName,
365
+ uncompressedSize,
366
+ compressedSize,
367
+ compressionMethod,
368
+ crc32,
369
+ lastModified: parseLastModified(lastModFileDate, lastModFileTime, extraFields),
370
+ isDirectory: fileName.endsWith('/'),
371
+ isEncrypted: (generalPurposeBitFlag & 0x1) !== 0,
372
+ comment: decodeBuffer(fileCommentRaw, hasUtf8Flag),
373
+ generalPurposeBitFlag,
374
+ relativeOffsetOfLocalHeader,
375
+ })
376
+
377
+ cursor = entryEnd
378
+ }
379
+ return entries
380
+ }
381
+
382
+ interface ExtraField {
383
+ id: number
384
+ data: Buffer
385
+ }
386
+
387
+ function parseExtraFields(buf: Buffer): ExtraField[] {
388
+ const fields: ExtraField[] = []
389
+ let i = 0
390
+ while (i < buf.length - 3) {
391
+ const id = buf.readUInt16LE(i)
392
+ const dataSize = buf.readUInt16LE(i + 2)
393
+ const dataStart = i + 4
394
+ const dataEnd = dataStart + dataSize
395
+ if (dataEnd > buf.length) {
396
+ throw new Error('extra field length exceeds extra field buffer size')
397
+ }
398
+ fields.push({ id, data: buf.subarray(dataStart, dataEnd) })
399
+ i = dataEnd
400
+ }
401
+ return fields
402
+ }
403
+
404
+ function readZip64ExtraField(
405
+ extraFields: ExtraField[],
406
+ uncompressedSize: number,
407
+ compressedSize: number,
408
+ relativeOffsetOfLocalHeader: number,
409
+ ): { uncompressedSize: number; compressedSize: number; relativeOffsetOfLocalHeader: number } {
410
+ const zip64 = extraFields.find(f => f.id === 0x0001)
411
+ if (!zip64) {
412
+ return { uncompressedSize, compressedSize, relativeOffsetOfLocalHeader }
413
+ }
414
+ const { data } = zip64
415
+ let index = 0
416
+ const next = (): number => {
417
+ if (index + 8 > data.length) {
418
+ throw new Error('zip64 extended information extra field is too short')
419
+ }
420
+ const value = readUInt64LE(data, index)
421
+ index += 8
422
+ return value
423
+ }
424
+ // Fields appear in this fixed order, but only the ones using the sentinel are present.
425
+ if (uncompressedSize === 0xffffffff) uncompressedSize = next()
426
+ if (compressedSize === 0xffffffff) compressedSize = next()
427
+ if (relativeOffsetOfLocalHeader === 0xffffffff) relativeOffsetOfLocalHeader = next()
428
+ return { uncompressedSize, compressedSize, relativeOffsetOfLocalHeader }
429
+ }
430
+
431
+ function decodeFileName(
432
+ generalPurposeBitFlag: number,
433
+ fileNameRaw: Buffer,
434
+ extraFields: ExtraField[],
435
+ ): string {
436
+ // Info-ZIP Unicode Path Extra Field (0x7075): an authoritative UTF-8 name,
437
+ // used only if its stored CRC-32 matches the raw name. See yauzl#33.
438
+ const unicodePath = extraFields.find(f => f.id === 0x7075)
439
+ if (
440
+ unicodePath &&
441
+ unicodePath.data.length >= 6 &&
442
+ unicodePath.data.readUInt8(0) === 1 &&
443
+ unicodePath.data.readUInt32LE(1) === zlib.crc32(fileNameRaw)
444
+ ) {
445
+ return normalizeZipEntryName(unicodePath.data.subarray(5).toString('utf8'))
446
+ }
447
+
448
+ const hasUtf8Flag = (generalPurposeBitFlag & 0x800) !== 0
449
+ return normalizeZipEntryName(decodeBuffer(fileNameRaw, hasUtf8Flag))
450
+ }
451
+
452
+ function parseLastModified(date: number, time: number, extraFields: ExtraField[]): UnixTimestamp {
453
+ // Prefer the Info-ZIP "UT" extended timestamp (0x5455) if it carries mtime.
454
+ // Its payload is already a Unix timestamp in seconds.
455
+ const ut = extraFields.find(f => f.id === 0x5455)
456
+ if (ut && ut.data.length >= 5 && (ut.data.readUInt8(0) & 0x01) !== 0) {
457
+ return ut.data.readInt32LE(1) as UnixTimestamp
458
+ }
459
+ return dosDateTimeToUnix(date, time)
460
+ }
461
+
462
+ function decodeBuffer(buf: Buffer, hasUtf8Flag: boolean): string {
463
+ // Many tools (Info-ZIP, Linux `zip`) store UTF-8 names without setting the UTF-8
464
+ // flag, so also trust UTF-8 when the bytes are valid UTF-8; else fall back to cp437.
465
+ if (hasUtf8Flag || isUtf8(buf)) return buf.toString('utf8')
466
+ // Legacy cp437: ASCII passthrough for 0x00-0x7f, lookup table for the high half.
467
+ let result = ''
468
+ for (const byte of buf) {
469
+ result += byte < 0x80 ? String.fromCodePoint(byte) : CP437_HIGH.charAt(byte - 0x80)
470
+ }
471
+ return result
472
+ }
473
+
474
+ function assertPathInside(root: string, fileName: string): string {
475
+ const targetPath = path.resolve(root, fileName)
476
+ if (targetPath !== root && !targetPath.startsWith(root + path.sep)) {
477
+ throw new Error(`zip entry escapes destination directory: ${fileName}`)
478
+ }
479
+ return targetPath
480
+ }
481
+
482
+ /**
483
+ * Random-access byte source backing a {@link ZipReader}.
484
+ */
485
+ interface ZipSource {
486
+ readonly size: number
487
+ /** Read exactly `length` bytes starting at `position`; throws on EOF. */
488
+ read: (position: number, length: number) => Promise<Buffer>
489
+ /** Stream raw bytes in the `[start, end)` range. */
490
+ createReadStream: (start: number, end: number) => Readable
491
+ /** Release any held resources. Idempotent. */
492
+ close: () => Promise<void>
493
+ }
494
+
495
+ class FileSource implements ZipSource {
496
+ private closed = false
497
+ constructor(
498
+ private fileHandle: FileHandle,
499
+ private filePath: string,
500
+ readonly size: number,
501
+ ) {}
502
+
503
+ async read(position: number, length: number): Promise<Buffer> {
504
+ if (length === 0) return Buffer.alloc(0)
505
+ const buf = Buffer.allocUnsafe(length)
506
+ let read = 0
507
+ while (read < length) {
508
+ const { bytesRead } = await this.fileHandle.read(buf, read, length - read, position + read)
509
+ if (bytesRead === 0) {
510
+ throw new Error(`unexpected EOF: read ${read} of ${length} bytes at offset ${position}`)
511
+ }
512
+ read += bytesRead
513
+ }
514
+ return buf
515
+ }
516
+
517
+ createReadStream(start: number, end: number): Readable {
518
+ if (start >= end) return Readable.from([])
519
+ // Stream via a fresh, self-contained fd rather than the shared FileHandle: its
520
+ // own autoClose closes only that fd on stream end/destroy, leaving the handle
521
+ // (used for positioned header reads) intact. `end` is inclusive here, hence -1.
522
+ return createReadStream(this.filePath, { start, end: end - 1 })
523
+ }
524
+
525
+ async close(): Promise<void> {
526
+ if (this.closed) return
527
+ this.closed = true
528
+ await this.fileHandle.close()
529
+ }
530
+ }
531
+
532
+ class BufferSource implements ZipSource {
533
+ constructor(private buffer: Buffer) {}
534
+
535
+ get size(): number {
536
+ return this.buffer.length
537
+ }
538
+
539
+ async read(position: number, length: number): Promise<Buffer> {
540
+ if (position + length > this.buffer.length) {
541
+ throw new Error(`unexpected EOF: cannot read ${length} bytes at offset ${position}`)
542
+ }
543
+ return this.buffer.subarray(position, position + length)
544
+ }
545
+
546
+ createReadStream(start: number, end: number): Readable {
547
+ if (start >= end) return Readable.from([])
548
+ return Readable.from(chunkBuffer(this.buffer.subarray(start, end)))
549
+ }
550
+
551
+ async close(): Promise<void> {
552
+ return
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Split a buffer into smaller chunks for friendlier memory usage when piping
558
+ * into a decompression stream. See yauzl#87.
559
+ */
560
+ function* chunkBuffer(buf: Buffer, chunkSize = 0x10000): Generator<Buffer> {
561
+ for (let offset = 0; offset < buf.length; offset += chunkSize) {
562
+ yield buf.subarray(offset, offset + chunkSize)
563
+ }
564
+ }
565
+
566
+ // cp437 high half (bytes 0x80-0xff), used to decode legacy (non-UTF-8) names/comments.
567
+ // The last entry (0xff) is a non-breaking space (U+00A0).
568
+ const CP437_HIGH =
569
+ 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ '
570
+
571
+ /**
572
+ * A single entry (file or directory) inside a zip archive,
573
+ * as parsed from its central directory record.
574
+ */
575
+ export interface ZipEntry {
576
+ /**
577
+ * Entry name, using `/` as the path separator.
578
+ * Directory entries end with a trailing `/`.
579
+ */
580
+ fileName: string
581
+ /**
582
+ * Uncompressed size, in bytes.
583
+ */
584
+ uncompressedSize: number
585
+ /**
586
+ * Compressed size, in bytes.
587
+ */
588
+ compressedSize: number
589
+ /**
590
+ * Compression method: `0` = stored (no compression), `8` = deflate.
591
+ * Other methods cannot be read.
592
+ */
593
+ compressionMethod: number
594
+ /**
595
+ * Expected CRC-32 checksum of the uncompressed data.
596
+ */
597
+ crc32: number
598
+ /**
599
+ * Last modification time of the entry, as a Unix timestamp in seconds.
600
+ */
601
+ lastModified: UnixTimestamp
602
+ /**
603
+ * True if the entry is a directory (its `fileName` ends with `/`).
604
+ */
605
+ isDirectory: boolean
606
+ /**
607
+ * True if the entry is encrypted. Encrypted entries cannot be read.
608
+ */
609
+ isEncrypted: boolean
610
+ /**
611
+ * Optional per-entry comment (empty string if none).
612
+ */
613
+ comment: string
614
+
615
+ // Low-level fields needed to locate the entry's data within the archive.
616
+ /** Bit flags from the central directory record. */
617
+ generalPurposeBitFlag: number
618
+ /** Byte offset of the entry's local file header. */
619
+ relativeOffsetOfLocalHeader: number
620
+ }