@naturalcycles/nodejs-lib 15.109.0 → 15.110.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,792 @@
1
+ /*
2
+
3
+ A minimal, dependency-free zip archive writer for Node.js 24+.
4
+
5
+ Adapted from yazl (https://github.com/thejoshwolfe/yazl) 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
+ (used incrementally while streaming), and the deflate is done with `node:zlib`.
10
+ - Promise API instead of callbacks/EventEmitter: `await writer.addFile(...)` etc.,
11
+ finished with `await writer.finalize()`.
12
+ - Streaming entries (`addStream`/`addFile`) are written with a data descriptor
13
+ (general purpose bit 3), so their size and CRC need not be known up front.
14
+ - Buffer entries (`addBuffer`) are written with their sizes and CRC inline.
15
+
16
+ Kept from yazl: ZIP64 output (auto-enabled past the 4 GiB / 0xffff limits, or via
17
+ `forceZip64`), the Info-ZIP extended timestamp (0x5455) for accurate mtimes, DOS
18
+ date/time encoding, file-name validation and per-entry/archive comments.
19
+
20
+ Shares the low-level format details (signatures, sizes, 64-bit and DOS date/time
21
+ helpers, name validation) with the reader via `zipInternal.ts`.
22
+
23
+ */
24
+
25
+ import { createReadStream, createWriteStream } from 'node:fs'
26
+ import fsp from 'node:fs/promises'
27
+ import path from 'node:path'
28
+ import type { Readable } from 'node:stream'
29
+ import { Transform, Writable } from 'node:stream'
30
+ import { finished, pipeline } from 'node:stream/promises'
31
+ import { promisify } from 'node:util'
32
+ import zlib from 'node:zlib'
33
+ import { comparators } from '@naturalcycles/js-lib/array/sort.js'
34
+ import type { UnixTimestamp } from '@naturalcycles/js-lib/types'
35
+ import { glob } from 'tinyglobby'
36
+ import {
37
+ assertSafeZipEntryName,
38
+ CDFH_SIG,
39
+ CDFH_SIZE,
40
+ DATA_DESCRIPTOR_SIG,
41
+ DEFLATE,
42
+ EOCDR_SIG,
43
+ EOCDR_SIZE,
44
+ LOCAL_FILE_HEADER_SIG,
45
+ MAX_COMMENT_SIZE,
46
+ normalizeZipEntryName,
47
+ STORED,
48
+ unixToDosDateTime,
49
+ writeUInt64LE,
50
+ ZIP64_EOCDL_SIG,
51
+ ZIP64_EOCDL_SIZE,
52
+ ZIP64_EOCDR_SIG,
53
+ ZIP64_EOCDR_SIZE,
54
+ } from './zipInternal.js'
55
+
56
+ // oxlint-disable no-bitwise, unicorn/prefer-math-trunc -- writing the binary ZIP format requires bitwise ops on bit flags, packed DOS date/time fields, and unsigned 32-bit attribute packing
57
+
58
+ const deflateRawAsync = promisify(zlib.deflateRaw.bind(zlib))
59
+
60
+ /**
61
+ * Create a zip archive on disk from a list of files and/or directories.
62
+ *
63
+ * Each input path may be a file (added directly) or a directory (walked
64
+ * recursively; all nested files are added). By default an entry's name is its
65
+ * path relative to its input's parent directory, so `zipFiles(['./photos'], 'out.zip')`
66
+ * stores entries under `photos/...`. Override the base via {@link ZipPathsOptions.baseDir}.
67
+ *
68
+ * Files discovered by walking a directory are added in deterministic (sorted)
69
+ * order; the explicit input order is otherwise preserved.
70
+ *
71
+ * ```ts
72
+ * await zipPaths(['./photos'], 'photos.zip') // a whole directory
73
+ * await zipPaths(['a.txt', 'log/b.txt'], 'out.zip') // a list of files
74
+ * ```
75
+ *
76
+ * On failure the partially-written archive is removed.
77
+ */
78
+ export async function zipPaths(
79
+ paths: string[],
80
+ outputZipFilePath: string,
81
+ opt: ZipPathsOptions = {},
82
+ ): Promise<void> {
83
+ const { baseDir, ...entryOpt } = opt
84
+ const inputs = paths.map(p => path.resolve(p))
85
+ const files = await collectFiles(inputs, baseDir)
86
+
87
+ // Build the write stream here (rather than via createZip) so a mid-way failure
88
+ // can tear down the stream and remove the half-written archive.
89
+ const out = createWriteStream(outputZipFilePath)
90
+ const writer = new ZipWriter(out)
91
+ try {
92
+ for (const file of files) {
93
+ await writer.addFile(file.absPath, file.name, entryOpt)
94
+ }
95
+ await writer.finalize()
96
+ } catch (err) {
97
+ out.destroy()
98
+ await fsp.rm(outputZipFilePath, { force: true })
99
+ throw err
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build a zip archive entirely in memory and return it as a Buffer.
105
+ *
106
+ * For large archives or streamed inputs prefer {@link createZip} / {@link ZipWriter}.
107
+ */
108
+ export async function createZipBuffer(entries: ZipFileEntry[]): Promise<Buffer> {
109
+ const chunks: Buffer[] = []
110
+ const sink = new Writable({
111
+ write(chunk: Buffer, _enc, cb) {
112
+ chunks.push(chunk)
113
+ cb()
114
+ },
115
+ })
116
+ const writer = new ZipWriter(sink)
117
+ for (const { name, content, ...opt } of entries) {
118
+ if (content === undefined) {
119
+ await writer.addDirectory(name, opt)
120
+ } else {
121
+ await writer.addBuffer(content, name, opt)
122
+ }
123
+ }
124
+ await writer.finalize()
125
+ return Buffer.concat(chunks)
126
+ }
127
+
128
+ /**
129
+ * Options for {@link zipPaths}.
130
+ */
131
+ export interface ZipPathsOptions extends ZipWriterEntryOptions {
132
+ /**
133
+ * Base directory used to compute entry names: each file is stored under its path
134
+ * relative to `baseDir`.
135
+ *
136
+ * Defaults to the parent of each input path, so a file `/a/b.txt` is stored as
137
+ * `b.txt` and a directory `/a/photos` is stored under `photos/...` (its own name
138
+ * is preserved). Pass the directory itself as `baseDir` to instead place its
139
+ * contents at the archive root.
140
+ */
141
+ baseDir?: string
142
+ }
143
+
144
+ /**
145
+ * Writes a zip archive to a Node.js {@link Writable} stream.
146
+ *
147
+ * Create one directly over any `Writable`, or use {@link createZip} to write to
148
+ * a file. Add entries sequentially (each add method must be awaited before the
149
+ * next), then call {@link finalize} to write the central directory and close the
150
+ * stream.
151
+ *
152
+ * Implements `AsyncDisposable`, so `await using` finalizes automatically on scope
153
+ * exit:
154
+ *
155
+ * ```ts
156
+ * await using zip = createZip('archive.zip')
157
+ * await zip.addBuffer(Buffer.from('hello'), 'hello.txt')
158
+ * // zip.finalize() is called automatically here
159
+ * ```
160
+ */
161
+ export class ZipWriter implements AsyncDisposable {
162
+ private offset = 0
163
+ private finalized = false
164
+ private streamError?: Error
165
+ private readonly entries: WriteEntry[] = []
166
+
167
+ constructor(private out: Writable) {
168
+ // Capture stream errors so a pending/next write rejects instead of the
169
+ // 'error' event going unhandled and crashing the process.
170
+ this.out.once('error', err => {
171
+ this.streamError ??= err
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Add an in-memory buffer as a file entry.
177
+ * The CRC-32 and sizes are computed up front and written inline (no data descriptor).
178
+ */
179
+ async addBuffer(data: Buffer, fileName: string, opt: ZipWriterEntryOptions = {}): Promise<void> {
180
+ this.assertWritable()
181
+ const entry = this.createEntry(fileName, false, opt)
182
+ entry.crc32 = zlib.crc32(data)
183
+ entry.uncompressedSize = data.length
184
+ const stored =
185
+ entry.method === STORED ? data : await deflateRawAsync(data, { level: entry.level })
186
+ entry.compressedSize = stored.length
187
+ entry.crcAndFileSizeKnown = true
188
+ await this.writeKnownEntry(entry, stored)
189
+ }
190
+
191
+ /**
192
+ * Add a file from disk, streaming its contents. The entry's mtime and mode
193
+ * default to the file's own (override via `opt`). If `fileName` is omitted,
194
+ * the file's base name is used.
195
+ */
196
+ async addFile(
197
+ filePath: string,
198
+ fileName?: string,
199
+ opt: ZipWriterEntryOptions = {},
200
+ ): Promise<void> {
201
+ this.assertWritable()
202
+ const stats = await fsp.stat(filePath)
203
+ const entry = this.createEntry(fileName ?? path.basename(filePath), false, {
204
+ mtime: Math.floor(stats.mtimeMs / 1000) as UnixTimestamp,
205
+ mode: stats.mode & 0xffff,
206
+ ...opt,
207
+ })
208
+ await this.pumpEntry(entry, createReadStream(filePath))
209
+ }
210
+
211
+ /**
212
+ * Add a readable stream as a file entry. The size and CRC are computed while
213
+ * streaming and written in a trailing data descriptor.
214
+ */
215
+ async addStream(
216
+ stream: Readable,
217
+ fileName: string,
218
+ opt: ZipWriterEntryOptions = {},
219
+ ): Promise<void> {
220
+ this.assertWritable()
221
+ const entry = this.createEntry(fileName, false, opt)
222
+ await this.pumpEntry(entry, stream)
223
+ }
224
+
225
+ /**
226
+ * Add an explicit (empty) directory entry. A trailing `/` is added if missing.
227
+ * Directory entries are optional in zip archives but make empty directories explicit.
228
+ */
229
+ async addDirectory(fileName: string, opt: ZipWriterEntryOptions = {}): Promise<void> {
230
+ this.assertWritable()
231
+ const entry = this.createEntry(fileName, true, opt)
232
+ await this.writeKnownEntry(entry, EMPTY)
233
+ }
234
+
235
+ /**
236
+ * Write the central directory and end-of-central-directory records, then end
237
+ * the underlying stream and wait for it to flush. Idempotent.
238
+ */
239
+ async finalize(opt: ZipFinalizeOptions = {}): Promise<void> {
240
+ if (this.finalized) return
241
+ const comment = opt.comment ? Buffer.from(opt.comment, 'utf8') : EMPTY
242
+ if (comment.length > MAX_COMMENT_SIZE) {
243
+ throw new Error(`archive comment is too long: ${comment.length} > ${MAX_COMMENT_SIZE} bytes`)
244
+ }
245
+ // A comment containing this signature would confuse readers that scan
246
+ // backwards for the end-of-central-directory record.
247
+ if (comment.includes(EOCDR_SIG_BYTES)) {
248
+ throw new Error('archive comment must not contain the end-of-central-directory signature')
249
+ }
250
+ this.finalized = true
251
+
252
+ const centralDirectoryOffset = this.offset
253
+ for (const entry of this.entries) {
254
+ await this.write(buildCentralDirectoryRecord(entry))
255
+ }
256
+ const centralDirectorySize = this.offset - centralDirectoryOffset
257
+ await this.write(
258
+ buildEndRecords({
259
+ entryCount: this.entries.length,
260
+ centralDirectoryOffset,
261
+ centralDirectorySize,
262
+ comment,
263
+ zip64EocdrOffset: this.offset,
264
+ forceZip64: opt.forceZip64 ?? false,
265
+ }),
266
+ )
267
+ await this.finishStream()
268
+ }
269
+
270
+ /**
271
+ * Called by `await using`; finalizes the archive if not already done. See {@link finalize}.
272
+ */
273
+ async [Symbol.asyncDispose](): Promise<void> {
274
+ await this.finalize()
275
+ }
276
+
277
+ private assertWritable(): void {
278
+ if (this.streamError) throw this.streamError
279
+ if (this.finalized) throw new Error('ZipWriter has already been finalized')
280
+ }
281
+
282
+ /** Build the in-memory representation of an entry from its name and options. */
283
+ private createEntry(
284
+ fileName: string,
285
+ isDirectory: boolean,
286
+ opt: ZipWriterEntryOptions,
287
+ ): WriteEntry {
288
+ const nameBuf = Buffer.from(normalizeAndValidateEntryName(fileName, isDirectory), 'utf8')
289
+ if (nameBuf.length > MAX_COMMENT_SIZE) {
290
+ throw new Error(`zip entry name is too long: ${nameBuf.length} > ${MAX_COMMENT_SIZE} bytes`)
291
+ }
292
+ const mtime = opt.mtime ?? (Math.floor(Date.now() / 1000) as UnixTimestamp)
293
+ const { date, time } = unixToDosDateTime(mtime)
294
+ const mode = opt.mode ?? (isDirectory ? DEFAULT_DIR_MODE : DEFAULT_FILE_MODE)
295
+ if ((mode & 0xffff) !== mode) {
296
+ throw new Error(`invalid mode: expected 0 <= ${mode} <= 65535`)
297
+ }
298
+ const compress = isDirectory ? false : (opt.compress ?? true)
299
+ const level = compress ? (opt.level ?? DEFAULT_DEFLATE_LEVEL) : 0
300
+ const commentBuf = opt.comment ? Buffer.from(opt.comment, 'utf8') : EMPTY
301
+ if (commentBuf.length > MAX_COMMENT_SIZE) {
302
+ throw new Error(
303
+ `zip entry comment is too long: ${commentBuf.length} > ${MAX_COMMENT_SIZE} bytes`,
304
+ )
305
+ }
306
+ return {
307
+ nameBuf,
308
+ isDirectory,
309
+ method: level === 0 ? STORED : DEFLATE,
310
+ level,
311
+ // Directories carry no data, so their (zero) sizes and CRC are known up front.
312
+ crcAndFileSizeKnown: isDirectory,
313
+ crc32: 0,
314
+ uncompressedSize: 0,
315
+ compressedSize: 0,
316
+ relativeOffsetOfLocalHeader: 0,
317
+ lastModFileTime: time,
318
+ lastModFileDate: date,
319
+ mtimeSeconds: clampInt32(mtime),
320
+ // Unix mode packed into the high 16 bits; `>>> 0` keeps it an unsigned uint32.
321
+ externalFileAttributes: (mode << 16) >>> 0,
322
+ commentBuf,
323
+ forceZip64: opt.forceZip64 ?? false,
324
+ }
325
+ }
326
+
327
+ /** Write an entry whose CRC and sizes are already known: header, name, data, no descriptor. */
328
+ private async writeKnownEntry(entry: WriteEntry, data: Buffer): Promise<void> {
329
+ entry.relativeOffsetOfLocalHeader = this.offset
330
+ await this.write(buildLocalFileHeader(entry))
331
+ await this.write(entry.nameBuf)
332
+ await this.write(data)
333
+ this.entries.push(entry)
334
+ }
335
+
336
+ /** Write a streamed entry: header with bit 3 set, streamed data, then a data descriptor. */
337
+ private async pumpEntry(entry: WriteEntry, source: Readable): Promise<void> {
338
+ entry.relativeOffsetOfLocalHeader = this.offset
339
+ await this.write(buildLocalFileHeader(entry))
340
+ await this.write(entry.nameBuf)
341
+ const { crc32, uncompressedSize, compressedSize } = await this.pumpData(
342
+ source,
343
+ entry.method === DEFLATE,
344
+ entry.level,
345
+ )
346
+ entry.crc32 = crc32
347
+ entry.uncompressedSize = uncompressedSize
348
+ entry.compressedSize = compressedSize
349
+ await this.write(buildDataDescriptor(entry, useZip64(entry)))
350
+ this.entries.push(entry)
351
+ }
352
+
353
+ /**
354
+ * Pipe `source` to the output, computing the CRC-32 and uncompressed size on
355
+ * the way in, optionally deflating, and counting the compressed bytes written.
356
+ */
357
+ private async pumpData(
358
+ source: Readable,
359
+ compress: boolean,
360
+ level: number,
361
+ ): Promise<{ crc32: number; uncompressedSize: number; compressedSize: number }> {
362
+ let crc32 = 0
363
+ let uncompressedSize = 0
364
+ let compressedSize = 0
365
+
366
+ // Tap the uncompressed bytes for the CRC-32 and size before they are deflated.
367
+ const tap = new Transform({
368
+ transform(chunk: Buffer, _enc, cb) {
369
+ crc32 = zlib.crc32(chunk, crc32)
370
+ uncompressedSize += chunk.length
371
+ cb(null, chunk)
372
+ },
373
+ })
374
+ // Final pipeline stage: write each (possibly compressed) chunk to the output,
375
+ // counting bytes. Awaiting `write` propagates backpressure up the pipeline.
376
+ const drain = async (src: AsyncIterable<Buffer>): Promise<void> => {
377
+ for await (const chunk of src) {
378
+ compressedSize += chunk.length
379
+ await this.write(chunk)
380
+ }
381
+ }
382
+
383
+ if (compress) {
384
+ await pipeline(source, tap, zlib.createDeflateRaw({ level }), drain)
385
+ } else {
386
+ await pipeline(source, tap, drain)
387
+ }
388
+ return { crc32, uncompressedSize, compressedSize }
389
+ }
390
+
391
+ /** Write a buffer to the output, tracking the byte offset and respecting backpressure. */
392
+ private async write(buf: Buffer): Promise<void> {
393
+ if (this.streamError) throw this.streamError
394
+ if (buf.length === 0) return
395
+ this.offset += buf.length
396
+ if (!this.out.write(buf)) {
397
+ await this.waitDrain()
398
+ }
399
+ }
400
+
401
+ private async waitDrain(): Promise<void> {
402
+ return new Promise((resolve, reject) => {
403
+ const cleanup = (): void => {
404
+ this.out.off('drain', onDrain)
405
+ this.out.off('error', onError)
406
+ }
407
+ const onDrain = (): void => {
408
+ cleanup()
409
+ resolve()
410
+ }
411
+ const onError = (err: Error): void => {
412
+ cleanup()
413
+ reject(err)
414
+ }
415
+ this.out.once('drain', onDrain)
416
+ this.out.once('error', onError)
417
+ })
418
+ }
419
+
420
+ private async finishStream(): Promise<void> {
421
+ this.out.end()
422
+ await finished(this.out)
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Internal, fully-resolved representation of an entry, accumulated until
428
+ * {@link ZipWriter.finalize} writes the central directory.
429
+ */
430
+ interface WriteEntry {
431
+ nameBuf: Buffer
432
+ isDirectory: boolean
433
+ /** Compression method: {@link STORED} or {@link DEFLATE}. */
434
+ method: number
435
+ /** Deflate level (0 = stored). */
436
+ level: number
437
+ /** True for buffer/directory entries (no data descriptor); false for streamed entries. */
438
+ crcAndFileSizeKnown: boolean
439
+ crc32: number
440
+ uncompressedSize: number
441
+ compressedSize: number
442
+ relativeOffsetOfLocalHeader: number
443
+ lastModFileTime: number
444
+ lastModFileDate: number
445
+ /** mtime as a Unix timestamp (seconds), for the Info-ZIP extended timestamp field. */
446
+ mtimeSeconds: number
447
+ /** Unix mode shifted into the high 16 bits, as stored in the central directory. */
448
+ externalFileAttributes: number
449
+ commentBuf: Buffer
450
+ forceZip64: boolean
451
+ }
452
+
453
+ function buildLocalFileHeader(entry: WriteEntry): Buffer {
454
+ const buf = Buffer.allocUnsafe(LOCAL_FILE_HEADER_SIZE)
455
+ let generalPurposeBitFlag = FILE_NAME_IS_UTF8
456
+ let crc32 = 0
457
+ let compressedSize = 0
458
+ let uncompressedSize = 0
459
+ if (entry.crcAndFileSizeKnown) {
460
+ crc32 = entry.crc32
461
+ compressedSize = entry.compressedSize
462
+ uncompressedSize = entry.uncompressedSize
463
+ } else {
464
+ // Sizes/CRC are unknown until the data has streamed; bit 3 says a data
465
+ // descriptor follows the file data.
466
+ generalPurposeBitFlag |= UNKNOWN_CRC32_AND_FILE_SIZES
467
+ }
468
+ buf.writeUInt32LE(LOCAL_FILE_HEADER_SIG, 0)
469
+ buf.writeUInt16LE(VERSION_NEEDED_UTF8, 4)
470
+ buf.writeUInt16LE(generalPurposeBitFlag, 6)
471
+ buf.writeUInt16LE(entry.method, 8)
472
+ buf.writeUInt16LE(entry.lastModFileTime, 10)
473
+ buf.writeUInt16LE(entry.lastModFileDate, 12)
474
+ buf.writeUInt32LE(crc32, 14)
475
+ buf.writeUInt32LE(compressedSize, 18)
476
+ buf.writeUInt32LE(uncompressedSize, 22)
477
+ buf.writeUInt16LE(entry.nameBuf.length, 26)
478
+ buf.writeUInt16LE(0, 28) // no extra field in the local header
479
+ return buf
480
+ }
481
+
482
+ function buildDataDescriptor(entry: WriteEntry, asZip64: boolean): Buffer {
483
+ if (!asZip64) {
484
+ const buf = Buffer.allocUnsafe(DATA_DESCRIPTOR_SIZE)
485
+ buf.writeUInt32LE(DATA_DESCRIPTOR_SIG, 0)
486
+ buf.writeUInt32LE(entry.crc32, 4)
487
+ buf.writeUInt32LE(entry.compressedSize, 8)
488
+ buf.writeUInt32LE(entry.uncompressedSize, 12)
489
+ return buf
490
+ }
491
+ const buf = Buffer.allocUnsafe(ZIP64_DATA_DESCRIPTOR_SIZE)
492
+ buf.writeUInt32LE(DATA_DESCRIPTOR_SIG, 0)
493
+ buf.writeUInt32LE(entry.crc32, 4)
494
+ writeUInt64LE(buf, entry.compressedSize, 8)
495
+ writeUInt64LE(buf, entry.uncompressedSize, 16)
496
+ return buf
497
+ }
498
+
499
+ function buildCentralDirectoryRecord(entry: WriteEntry): Buffer {
500
+ let generalPurposeBitFlag = FILE_NAME_IS_UTF8
501
+ if (!entry.crcAndFileSizeKnown) generalPurposeBitFlag |= UNKNOWN_CRC32_AND_FILE_SIZES
502
+
503
+ const timestampField = buildExtendedTimestampField(entry.mtimeSeconds)
504
+
505
+ // When ZIP64 is needed, the 32-bit fields hold the 0xffffffff sentinel and the
506
+ // real values live in the ZIP64 extended information extra field.
507
+ let compressedSize = entry.compressedSize
508
+ let uncompressedSize = entry.uncompressedSize
509
+ let localHeaderOffset = entry.relativeOffsetOfLocalHeader
510
+ let versionNeeded = VERSION_NEEDED_UTF8
511
+ let zip64Field = EMPTY
512
+ if (useZip64(entry)) {
513
+ compressedSize = 0xffffffff
514
+ uncompressedSize = 0xffffffff
515
+ localHeaderOffset = 0xffffffff
516
+ versionNeeded = VERSION_NEEDED_ZIP64
517
+ zip64Field = buildZip64ExtraField(entry)
518
+ }
519
+
520
+ const buf = Buffer.allocUnsafe(CDFH_SIZE)
521
+ buf.writeUInt32LE(CDFH_SIG, 0)
522
+ buf.writeUInt16LE(VERSION_MADE_BY, 4)
523
+ buf.writeUInt16LE(versionNeeded, 6)
524
+ buf.writeUInt16LE(generalPurposeBitFlag, 8)
525
+ buf.writeUInt16LE(entry.method, 10)
526
+ buf.writeUInt16LE(entry.lastModFileTime, 12)
527
+ buf.writeUInt16LE(entry.lastModFileDate, 14)
528
+ buf.writeUInt32LE(entry.crc32, 16)
529
+ buf.writeUInt32LE(compressedSize, 20)
530
+ buf.writeUInt32LE(uncompressedSize, 24)
531
+ buf.writeUInt16LE(entry.nameBuf.length, 28)
532
+ buf.writeUInt16LE(timestampField.length + zip64Field.length, 30)
533
+ buf.writeUInt16LE(entry.commentBuf.length, 32)
534
+ buf.writeUInt16LE(0, 34) // disk number start
535
+ buf.writeUInt16LE(0, 36) // internal file attributes
536
+ buf.writeUInt32LE(entry.externalFileAttributes, 38)
537
+ buf.writeUInt32LE(localHeaderOffset, 42)
538
+ return Buffer.concat([buf, entry.nameBuf, timestampField, zip64Field, entry.commentBuf])
539
+ }
540
+
541
+ /**
542
+ * Info-ZIP universal (extended) timestamp extra field (0x5455), central-directory
543
+ * variant: a single 32-bit UTC mtime. Gives 1-second, timezone-independent mtimes,
544
+ * which readers prefer over the coarse local-time DOS fields.
545
+ */
546
+ function buildExtendedTimestampField(mtimeSeconds: number): Buffer {
547
+ const buf = Buffer.allocUnsafe(INFO_ZIP_TIMESTAMP_FIELD_SIZE)
548
+ buf.writeUInt16LE(0x5455, 0)
549
+ buf.writeUInt16LE(INFO_ZIP_TIMESTAMP_FIELD_SIZE - 4, 2)
550
+ // Set both the mtime and atime flags to match Info-ZIP, even though only the
551
+ // mtime field follows (the central-directory variant never carries atime).
552
+ buf.writeUInt8(EB_UT_FL_MTIME | EB_UT_FL_ATIME, 4)
553
+ buf.writeInt32LE(mtimeSeconds, 5)
554
+ return buf
555
+ }
556
+
557
+ /** ZIP64 extended information extra field (0x0001) for a central directory record. */
558
+ function buildZip64ExtraField(entry: WriteEntry): Buffer {
559
+ const buf = Buffer.allocUnsafe(ZIP64_EIEF_SIZE)
560
+ buf.writeUInt16LE(0x0001, 0)
561
+ buf.writeUInt16LE(ZIP64_EIEF_SIZE - 4, 2)
562
+ // Order must match the 0xffffffff sentinels above: uncompressed, compressed, offset.
563
+ writeUInt64LE(buf, entry.uncompressedSize, 4)
564
+ writeUInt64LE(buf, entry.compressedSize, 12)
565
+ writeUInt64LE(buf, entry.relativeOffsetOfLocalHeader, 20)
566
+ return buf
567
+ }
568
+
569
+ function useZip64(entry: WriteEntry): boolean {
570
+ return (
571
+ entry.forceZip64 ||
572
+ entry.uncompressedSize > 0xfffffffe ||
573
+ entry.compressedSize > 0xfffffffe ||
574
+ entry.relativeOffsetOfLocalHeader > 0xfffffffe
575
+ )
576
+ }
577
+
578
+ /**
579
+ * Build the end-of-central-directory record, prefixed with the ZIP64 EOCD record
580
+ * and locator when the archive needs ZIP64 (too many entries, or a central
581
+ * directory that starts or extends past 4 GiB).
582
+ */
583
+ interface EndRecordsInput {
584
+ entryCount: number
585
+ centralDirectoryOffset: number
586
+ centralDirectorySize: number
587
+ comment: Buffer
588
+ /** Absolute offset where the ZIP64 EOCD record will be written (if needed). */
589
+ zip64EocdrOffset: number
590
+ forceZip64: boolean
591
+ }
592
+
593
+ function buildEndRecords(input: EndRecordsInput): Buffer {
594
+ const {
595
+ entryCount,
596
+ centralDirectoryOffset,
597
+ centralDirectorySize,
598
+ comment,
599
+ zip64EocdrOffset,
600
+ forceZip64,
601
+ } = input
602
+
603
+ let needZip64 = forceZip64
604
+ let normalEntryCount = entryCount
605
+ if (forceZip64 || entryCount >= 0xffff) {
606
+ normalEntryCount = 0xffff
607
+ needZip64 = true
608
+ }
609
+ let normalCentralDirectorySize = centralDirectorySize
610
+ if (forceZip64 || centralDirectorySize >= 0xffffffff) {
611
+ normalCentralDirectorySize = 0xffffffff
612
+ needZip64 = true
613
+ }
614
+ let normalCentralDirectoryOffset = centralDirectoryOffset
615
+ if (forceZip64 || centralDirectoryOffset >= 0xffffffff) {
616
+ normalCentralDirectoryOffset = 0xffffffff
617
+ needZip64 = true
618
+ }
619
+
620
+ const eocdr = Buffer.allocUnsafe(EOCDR_SIZE + comment.length)
621
+ eocdr.writeUInt32LE(EOCDR_SIG, 0)
622
+ eocdr.writeUInt16LE(0, 4) // number of this disk
623
+ eocdr.writeUInt16LE(0, 6) // disk with the start of the central directory
624
+ eocdr.writeUInt16LE(normalEntryCount, 8) // entries on this disk
625
+ eocdr.writeUInt16LE(normalEntryCount, 10) // total entries
626
+ eocdr.writeUInt32LE(normalCentralDirectorySize, 12)
627
+ eocdr.writeUInt32LE(normalCentralDirectoryOffset, 16)
628
+ eocdr.writeUInt16LE(comment.length, 20)
629
+ comment.copy(eocdr, 22)
630
+
631
+ if (!needZip64) return eocdr
632
+
633
+ const zip64Eocdr = Buffer.allocUnsafe(ZIP64_EOCDR_SIZE)
634
+ zip64Eocdr.writeUInt32LE(ZIP64_EOCDR_SIG, 0)
635
+ // size of this record, excluding the first 12 bytes (signature + this field)
636
+ writeUInt64LE(zip64Eocdr, ZIP64_EOCDR_SIZE - 12, 4)
637
+ zip64Eocdr.writeUInt16LE(VERSION_MADE_BY, 12)
638
+ zip64Eocdr.writeUInt16LE(VERSION_NEEDED_ZIP64, 14)
639
+ zip64Eocdr.writeUInt32LE(0, 16) // number of this disk
640
+ zip64Eocdr.writeUInt32LE(0, 20) // disk with the start of the central directory
641
+ writeUInt64LE(zip64Eocdr, entryCount, 24) // entries on this disk
642
+ writeUInt64LE(zip64Eocdr, entryCount, 32) // total entries
643
+ writeUInt64LE(zip64Eocdr, centralDirectorySize, 40)
644
+ writeUInt64LE(zip64Eocdr, centralDirectoryOffset, 48)
645
+
646
+ const locator = Buffer.allocUnsafe(ZIP64_EOCDL_SIZE)
647
+ locator.writeUInt32LE(ZIP64_EOCDL_SIG, 0)
648
+ locator.writeUInt32LE(0, 4) // disk with the ZIP64 end-of-central-directory record
649
+ writeUInt64LE(locator, zip64EocdrOffset, 8)
650
+ locator.writeUInt32LE(1, 16) // total number of disks
651
+
652
+ return Buffer.concat([zip64Eocdr, locator, eocdr])
653
+ }
654
+
655
+ function normalizeAndValidateEntryName(fileName: string, isDirectory: boolean): string {
656
+ if (!fileName) throw new Error('zip entry name must not be empty')
657
+ let name = normalizeZipEntryName(fileName)
658
+ if (isDirectory) {
659
+ if (!name.endsWith('/')) name += '/'
660
+ } else if (name.endsWith('/')) {
661
+ throw new Error(`file entry name must not end with "/": ${fileName}`)
662
+ }
663
+ assertSafeZipEntryName(name)
664
+ return name
665
+ }
666
+
667
+ function clampInt32(n: number): number {
668
+ if (n < -0x80000000) return -0x80000000
669
+ if (n > 0x7fffffff) return 0x7fffffff
670
+ return n
671
+ }
672
+
673
+ interface FileToZip {
674
+ absPath: string
675
+ /** Entry name inside the archive (forward-slash separated). */
676
+ name: string
677
+ }
678
+
679
+ /**
680
+ * Expand input paths (files and/or directories) into a flat list of files to add,
681
+ * resolving each entry's archive name relative to `baseDir` (or each input's parent).
682
+ */
683
+ async function collectFiles(inputs: string[], baseDir: string | undefined): Promise<FileToZip[]> {
684
+ const files: FileToZip[] = []
685
+ for (const input of inputs) {
686
+ const stats = await fsp.stat(input)
687
+ const base = baseDir ? path.resolve(baseDir) : path.dirname(input)
688
+ if (stats.isDirectory()) {
689
+ // `glob` does the recursive walk and (with the default `onlyFiles`) drops
690
+ // directories; `**` is the only pattern and `input` is the cwd (never
691
+ // interpreted), so paths containing glob metacharacters stay safe. Sorted
692
+ // for deterministic archives.
693
+ const relPaths = (await glob('**', { cwd: input, dot: true })).sort(comparators.localeAsc)
694
+ for (const rel of relPaths) {
695
+ const absPath = path.join(input, rel)
696
+ files.push({ absPath, name: toEntryName(path.relative(base, absPath)) })
697
+ }
698
+ } else {
699
+ files.push({ absPath: input, name: toEntryName(path.relative(base, input)) })
700
+ }
701
+ }
702
+ return files
703
+ }
704
+
705
+ /** Convert an OS-native relative path into a forward-slash zip entry name. */
706
+ function toEntryName(relPath: string): string {
707
+ return path.sep === '/' ? relPath : relPath.replaceAll(path.sep, '/')
708
+ }
709
+
710
+ const EMPTY: Buffer = Buffer.alloc(0)
711
+ const LOCAL_FILE_HEADER_SIZE = 30
712
+ const DATA_DESCRIPTOR_SIZE = 16
713
+ const ZIP64_DATA_DESCRIPTOR_SIZE = 24
714
+ const INFO_ZIP_TIMESTAMP_FIELD_SIZE = 9
715
+ const ZIP64_EIEF_SIZE = 28
716
+ // version made by: 3 (Unix) in the high byte, spec version 6.3 (63) in the low byte.
717
+ const VERSION_MADE_BY = (3 << 8) | 63
718
+ const VERSION_NEEDED_UTF8 = 20
719
+ const VERSION_NEEDED_ZIP64 = 45
720
+ const FILE_NAME_IS_UTF8 = 1 << 11
721
+ const UNKNOWN_CRC32_AND_FILE_SIZES = 1 << 3
722
+ const EB_UT_FL_MTIME = 1 << 0
723
+ const EB_UT_FL_ATIME = 1 << 1
724
+ const DEFAULT_FILE_MODE = 0o100664
725
+ const DEFAULT_DIR_MODE = 0o40775
726
+ const DEFAULT_DEFLATE_LEVEL = 6
727
+ // The 4-byte end-of-central-directory signature, as bytes, for comment validation.
728
+ const EOCDR_SIG_BYTES = Buffer.from([0x50, 0x4b, 0x05, 0x06])
729
+
730
+ /**
731
+ * Per-entry options for {@link ZipWriter} add methods.
732
+ */
733
+ export interface ZipWriterEntryOptions {
734
+ /**
735
+ * Compress the entry with deflate. Default `true` for files, always `false`
736
+ * for directories. Set to `false` to store the bytes uncompressed.
737
+ */
738
+ compress?: boolean
739
+ /**
740
+ * Deflate level, `0`-`9`. Implies compression; `0` means stored.
741
+ * Default `6`. Ignored when {@link compress} is `false`.
742
+ */
743
+ level?: number
744
+ /**
745
+ * Last modification time, as a Unix timestamp in seconds. Default: now.
746
+ */
747
+ mtime?: UnixTimestamp
748
+ /**
749
+ * Unix file mode bits (low 16 bits), e.g. `0o644`.
750
+ * Default `0o664` for files, `0o775` for directories.
751
+ */
752
+ mode?: number
753
+ /**
754
+ * Optional per-entry comment.
755
+ */
756
+ comment?: string
757
+ /**
758
+ * Force ZIP64 format for this entry. ZIP64 is also enabled automatically for
759
+ * entries larger than ~4 GiB or located past the 4 GiB offset; set this when
760
+ * adding a stream you know will be large.
761
+ */
762
+ forceZip64?: boolean
763
+ }
764
+
765
+ /**
766
+ * Options for {@link ZipWriter.finalize}.
767
+ */
768
+ export interface ZipFinalizeOptions {
769
+ /**
770
+ * Archive-level comment. Must not contain the end-of-central-directory signature.
771
+ */
772
+ comment?: string
773
+ /**
774
+ * Force ZIP64 end-of-central-directory records, regardless of size/count.
775
+ */
776
+ forceZip64?: boolean
777
+ }
778
+
779
+ /**
780
+ * A single entry for {@link createZipBuffer}.
781
+ */
782
+ export interface ZipFileEntry extends ZipWriterEntryOptions {
783
+ /**
784
+ * Entry name (path inside the archive), using `/` as the separator.
785
+ */
786
+ name: string
787
+ /**
788
+ * File contents. Omit to create a directory entry (a trailing `/` is added
789
+ * to {@link name} if missing). An empty `content` still creates a file.
790
+ */
791
+ content?: Buffer
792
+ }