@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.
- package/dist/zip/index.d.ts +3 -0
- package/dist/zip/index.js +3 -0
- package/dist/zip/zip2.d.ts +37 -0
- package/dist/zip/zip2.js +51 -0
- package/dist/zip/zipInternal.d.ts +41 -0
- package/dist/zip/zipInternal.js +88 -0
- package/dist/zip/zipReader.d.ts +142 -0
- package/dist/zip/zipReader.js +475 -0
- package/dist/zip/zipWriter.d.ts +168 -0
- package/dist/zip/zipWriter.js +579 -0
- package/package.json +3 -3
- package/src/jwt/jwt.service.ts +1 -0
- package/src/stream/transform/worker/workerClassProxy.js +1 -0
- package/src/validation/ajv/jSchema.ts +10 -10
- package/src/zip/index.ts +3 -0
- package/src/zip/zip2.ts +56 -0
- package/src/zip/zipInternal.ts +111 -0
- package/src/zip/zipReader.ts +620 -0
- package/src/zip/zipWriter.ts +792 -0
|
@@ -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
|
+
}
|