Repository navigation
Expand file tree
/
Copy pathpack.ts
More file actions
425 lines (375 loc) · 15.2 KB
/
Copy pathpack.ts
File metadata and controls
425 lines (375 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
import type { ITerminal } from '@rushstack/terminal/lib/ITerminal';
import { crc32Builder } from './crc32';
import { DISPOSE_SYMBOL, getDisposableFileHandle, type IDisposableFileHandle } from './fs';
import { type IIncrementalZlib, type IncrementalZlibMode, createIncrementalZlib } from './compress';
import { markStart, markEnd, getDuration, emitSummary, formatDuration } from './perf';
import {
writeLocalFileHeader,
writeDataDescriptor,
writeCentralDirectoryHeader,
writeEndOfCentralDirectory,
ZSTD_COMPRESSION,
DEFLATE_COMPRESSION,
STORE_COMPRESSION,
type ZipMetaCompressionMethod,
type IFileEntry,
dosDateTime
} from './zipUtils';
import { calculateSHA1 } from './hash';
import {
type ZipSyncOptionCompression,
type IMetadata,
type IDirQueueItem,
METADATA_VERSION,
METADATA_FILENAME,
defaultBufferSize
} from './zipSyncUtils';
/**
* File extensions for which additional DEFLATE/ZSTD compression is unlikely to help.
* Used by the 'auto' compression heuristic to avoid wasting CPU on data that is already
* compressed (images, media, existing archives, fonts, etc.).
*/
const LIKELY_COMPRESSED_EXTENSION_REGEX: RegExp =
/\.(?:zip|gz|tgz|bz2|xz|7z|rar|jpg|jpeg|png|gif|webp|avif|mp4|m4v|mov|mkv|webm|mp3|ogg|aac|flac|pdf|woff|woff2)$/;
/**
* Basic heuristic: skip re-compressing file types that are already compressed.
*/
function isLikelyAlreadyCompressed(filename: string): boolean {
return LIKELY_COMPRESSED_EXTENSION_REGEX.test(filename.toLowerCase());
}
/**
* Map zip compression method code -> incremental zlib mode label
*/
const zlibPackModes: Record<ZipMetaCompressionMethod, IncrementalZlibMode | undefined> = {
[ZSTD_COMPRESSION]: 'zstd-compress',
[DEFLATE_COMPRESSION]: 'deflate',
[STORE_COMPRESSION]: undefined
} as const;
/**
* Public facing CLI option -> actual zip method used for a file we decide to compress.
*/
const zipSyncCompressionOptions: Record<ZipSyncOptionCompression, ZipMetaCompressionMethod> = {
store: STORE_COMPRESSION,
deflate: DEFLATE_COMPRESSION,
zstd: ZSTD_COMPRESSION,
auto: DEFLATE_COMPRESSION
} as const;
/**
* @public
* Options for zipsync
*/
export interface IZipSyncPackOptions {
/**
* \@rushstack/terminal compatible terminal for logging
*/
terminal: ITerminal;
/**
* Zip file path
*/
archivePath: string;
/**
* Target directories to pack (relative to baseDir)
*/
targetDirectories: ReadonlyArray<string>;
/**
* Base directory for relative paths within the archive (defaults to common parent of targetDirectories)
*/
baseDir: string;
/**
* Compression mode. If set to 'deflate', file data will be compressed using raw DEFLATE (method 8) when this
* produces a smaller result; otherwise it will fall back to 'store' per-file.
*/
compression: ZipSyncOptionCompression;
/**
* Optional buffer that can be provided to avoid internal allocations.
*/
inputBuffer?: Buffer<ArrayBuffer>;
/**
* Optional buffer that can be provided to avoid internal allocations.
*/
outputBuffer?: Buffer<ArrayBuffer>;
}
export interface IZipSyncPackResult {
filesPacked: number;
metadata: IMetadata;
}
/**
* Create a zipsync archive by enumerating target directories, then streaming each file into the
* output zip using the local file header + (optional compressed data) + data descriptor pattern.
*
* Performance characteristics:
* - Single pass per file (no read-then-compress-then-write buffering). CRC32 + SHA-1 are computed
* while streaming so the metadata JSON can later be used for selective unpack.
* - Data descriptor usage (bit 3) allows writing headers before we know sizes or CRC32.
* - A single timestamp (captured once) is applied to all entries for determinism.
* - Metadata entry is added as a normal zip entry at the end (before central directory) so legacy
* tools can still list/extract it, while zipsync can quickly parse file hashes.
*/
export function pack({
archivePath,
targetDirectories: rawTargetDirectories,
baseDir: rawBaseDir,
compression,
terminal,
inputBuffer = Buffer.allocUnsafeSlow(defaultBufferSize),
outputBuffer = Buffer.allocUnsafeSlow(defaultBufferSize)
}: IZipSyncPackOptions): IZipSyncPackResult {
const baseDir: string = path.resolve(rawBaseDir);
const targetDirectories: string[] = rawTargetDirectories.map((dir) => path.join(baseDir, dir));
terminal.writeLine(`Packing to ${archivePath} from ${rawTargetDirectories.join(', ')}`);
markStart('pack.total');
terminal.writeDebugLine('Starting pack');
// Pass 1: enumerate files with a queue to avoid deep recursion
markStart('pack.enumerate');
const filePaths: string[] = [];
const queue: IDirQueueItem[] = targetDirectories.map((dir) => ({ dir, depth: 0 }));
while (queue.length) {
const { dir: currentDir, depth } = queue.shift()!;
terminal.writeDebugLine(`Enumerating directory: ${currentDir}`);
const padding: string = depth === 0 ? '' : '-↳'.repeat(depth);
let items: fs.Dirent[];
try {
items = fs.readdirSync(currentDir, { withFileTypes: true });
} catch (e) {
if (
e &&
((e as NodeJS.ErrnoException).code === 'ENOENT' || (e as NodeJS.ErrnoException).code === 'ENOTDIR')
) {
terminal.writeWarningLine(`Failed to read directory: ${currentDir}. Ignoring.`);
continue;
} else {
throw e;
}
}
for (const item of items) {
const fullPath: string = path.join(currentDir, item.name);
if (item.isFile()) {
const relativePath: string = path.relative(baseDir, fullPath).replace(/\\/g, '/');
terminal.writeVerboseLine(`${padding}${item.name}`);
filePaths.push(relativePath);
} else if (item.isDirectory()) {
terminal.writeVerboseLine(`${padding}${item.name}/`);
queue.push({ dir: fullPath, depth: depth + 1 });
} else {
throw new Error(`Unexpected item (not file or directory): ${fullPath}. Aborting.`);
}
}
}
terminal.writeLine(`Found ${filePaths.length} files to pack (enumerated)`);
markEnd('pack.enumerate');
// Pass 2: stream each file: read chunks -> hash + (maybe) compress -> write local header + data descriptor.
markStart('pack.prepareEntries');
terminal.writeDebugLine(`Opening archive for write: ${archivePath}`);
using zipFile: IDisposableFileHandle = getDisposableFileHandle(archivePath, 'w');
let currentOffset: number = 0;
/**
* Write a raw chunk to the archive file descriptor, updating current offset.
*/
function writeChunkToZip(chunk: Uint8Array, lengthBytes: number = chunk.byteLength): void {
let offset: number = 0;
while (lengthBytes > 0 && offset < chunk.byteLength) {
// In practice this call always writes all data at once, but the spec says it is not an error
// for it to not do so. Possibly that situation comes up when writing to something that is not
// an ordinary file.
const written: number = fs.writeSync(zipFile.fd, chunk, offset, lengthBytes);
lengthBytes -= written;
offset += written;
}
currentOffset += offset;
}
/** Convenience wrapper for writing multiple buffers sequentially. */
function writeChunksToZip(chunks: Uint8Array[]): void {
for (const chunk of chunks) {
writeChunkToZip(chunk);
}
}
const dosDateTimeNow: { time: number; date: number } = dosDateTime(new Date());
/**
* Stream a single file into the archive.
* Steps:
* 1. Decide compression (based on user choice + heuristic).
* 2. Emit local file header (sizes/CRC zeroed because we use a data descriptor).
* 3. Read file in 32 MiB chunks: update SHA-1 + CRC32; optionally feed compressor or write raw.
* 4. Flush compressor (if any) and write trailing data descriptor containing sizes + CRC.
* 5. Return populated entry metadata for later central directory + JSON metadata.
*/
function writeFileEntry(relativePath: string): IFileEntry {
const fullPath: string = path.join(baseDir, relativePath);
/**
* Read file in large fixed-size buffer; invoke callback for each filled chunk.
*/
const readInputInChunks: (onChunk: (bytesInInputBuffer: number) => void) => void = (
onChunk: (bytesInInputBuffer: number) => void
): void => {
using inputDisposable: IDisposableFileHandle = getDisposableFileHandle(fullPath, 'r');
let bytesInInputBuffer: number = 0;
// The entire input buffer will be drained in each loop iteration
// So run until EOF
while (!isNaN(inputDisposable.fd)) {
bytesInInputBuffer = fs.readSync(inputDisposable.fd, inputBuffer, 0, inputBuffer.byteLength, -1);
if (bytesInInputBuffer <= 0) {
// EOF, close the input fd
inputDisposable[DISPOSE_SYMBOL]();
}
onChunk(bytesInInputBuffer);
}
};
let shouldCompress: boolean = false;
if (compression === 'deflate' || compression === 'zstd') {
shouldCompress = true;
} else if (compression === 'auto') {
// Heuristic: skip compression for small files or likely-already-compressed files
if (!isLikelyAlreadyCompressed(relativePath)) {
shouldCompress = true;
} else {
terminal.writeVerboseLine(
`Skip compression heuristically (already-compressed) for ${relativePath} (size unknown at this point)`
);
}
}
const compressionMethod: ZipMetaCompressionMethod = shouldCompress
? zipSyncCompressionOptions[compression]
: zipSyncCompressionOptions.store;
const entry: IFileEntry = {
filename: relativePath,
size: 0,
compressedSize: 0,
crc32: 0,
sha1Hash: '',
localHeaderOffset: currentOffset,
compressionMethod,
dosDateTime: dosDateTimeNow
};
writeChunksToZip(writeLocalFileHeader(entry));
const sha1HashBuilder: crypto.Hash = crypto.createHash('sha1');
let crc32: number = 0;
let uncompressedSize: number = 0;
let compressedSize: number = 0;
/**
* Compressor instance (deflate or zstd) created only if needed.
*/
using incrementalZlib: IIncrementalZlib | undefined = shouldCompress
? createIncrementalZlib(
outputBuffer,
(chunk, lengthBytes) => {
writeChunkToZip(chunk, lengthBytes);
compressedSize += lengthBytes;
},
zlibPackModes[compressionMethod]!
)
: undefined;
// Read input file in chunks, update hashes, and either compress or write raw.
readInputInChunks((bytesInInputBuffer: number) => {
const slice: Buffer = inputBuffer.subarray(0, bytesInInputBuffer);
sha1HashBuilder.update(slice);
crc32 = crc32Builder(slice, crc32);
if (incrementalZlib) {
incrementalZlib.update(slice);
} else {
writeChunkToZip(slice, bytesInInputBuffer);
}
uncompressedSize += bytesInInputBuffer;
});
// finalize hashes, compression
incrementalZlib?.update(Buffer.alloc(0));
crc32 = crc32 >>> 0;
const sha1Hash: string = sha1HashBuilder.digest('hex');
if (!shouldCompress) {
compressedSize = uncompressedSize;
}
entry.size = uncompressedSize;
entry.compressedSize = compressedSize;
entry.crc32 = crc32;
entry.sha1Hash = sha1Hash;
// Trailing data descriptor now that final CRC/sizes are known.
writeChunkToZip(writeDataDescriptor(entry));
terminal.writeVerboseLine(
`${relativePath} (sha1=${entry.sha1Hash}, crc32=${entry.crc32.toString(16)}, size=${
entry.size
}, compressed=${entry.compressedSize}, method=${entry.compressionMethod}, compressed ${(
100 -
(entry.compressedSize / entry.size) * 100
).toFixed(1)}%)`
);
return entry;
}
const entries: IFileEntry[] = [];
// Emit all file entries in enumeration order.
for (const relativePath of filePaths) {
entries.push(writeFileEntry(relativePath));
}
markEnd('pack.prepareEntries');
terminal.writeLine(`Prepared ${entries.length} file entries`);
markStart('pack.metadata.build');
const metadata: IMetadata = { version: METADATA_VERSION, files: {} };
// Build metadata map used for selective unpack (size + SHA‑1 per file).
for (const entry of entries) {
metadata.files[entry.filename] = { size: entry.size, sha1Hash: entry.sha1Hash };
}
const metadataContent: string = JSON.stringify(metadata);
const metadataBuffer: Buffer = Buffer.from(metadataContent, 'utf8');
terminal.writeDebugLine(
`Metadata size=${metadataBuffer.length} bytes, fileCount=${Object.keys(metadata.files).length}`
);
let metadataCompressionMethod: ZipMetaCompressionMethod = zipSyncCompressionOptions.store;
let metadataData: Buffer = metadataBuffer;
let metadataCompressedSize: number = metadataBuffer.length;
// Compress metadata (deflate) iff user allowed compression and it helps (>64 bytes & smaller result).
if (compression !== 'store' && metadataBuffer.length > 64) {
const compressed: Buffer = zlib.deflateRawSync(metadataBuffer, { level: 9 });
if (compressed.length < metadataBuffer.length) {
metadataCompressionMethod = zipSyncCompressionOptions.deflate;
metadataData = compressed;
metadataCompressedSize = compressed.length;
terminal.writeDebugLine(
`Metadata compressed (orig=${metadataBuffer.length}, compressed=${compressed.length})`
);
} else {
terminal.writeDebugLine('Metadata compression skipped (not smaller)');
}
}
const metadataEntry: IFileEntry = {
filename: METADATA_FILENAME,
size: metadataBuffer.length,
compressedSize: metadataCompressedSize,
crc32: crc32Builder(metadataBuffer),
sha1Hash: calculateSHA1(metadataBuffer),
localHeaderOffset: currentOffset,
compressionMethod: metadataCompressionMethod,
dosDateTime: dosDateTimeNow
};
writeChunksToZip(writeLocalFileHeader(metadataEntry));
writeChunkToZip(metadataData, metadataCompressedSize);
writeChunkToZip(writeDataDescriptor(metadataEntry));
entries.push(metadataEntry);
terminal.writeVerboseLine(`Total entries including metadata: ${entries.length}`);
markEnd('pack.metadata.build');
markStart('pack.write.entries');
const outputDir: string = path.dirname(archivePath);
fs.mkdirSync(outputDir, { recursive: true });
markEnd('pack.write.entries');
markStart('pack.write.centralDirectory');
const centralDirOffset: number = currentOffset;
// Emit central directory records.
for (const entry of entries) {
writeChunksToZip(writeCentralDirectoryHeader(entry));
}
const centralDirSize: number = currentOffset - centralDirOffset;
markEnd('pack.write.centralDirectory');
// Write end of central directory
markStart('pack.write.eocd');
writeChunkToZip(writeEndOfCentralDirectory(centralDirOffset, centralDirSize, entries.length));
terminal.writeDebugLine('EOCD record written');
markEnd('pack.write.eocd');
markEnd('pack.total');
const total: number = getDuration('pack.total');
emitSummary('pack', terminal);
terminal.writeLine(`Successfully packed ${entries.length} files in ${formatDuration(total)}`);
return { filesPacked: entries.length, metadata };
}