mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Add ZIP archive support to the node:zlib module through three classes and a set of helpers: - ZipEntry: a single archive member, with buffered reads (content()), bounded-memory streaming reads (contentIterator()), and create()/createStream() for building members. - ZipFile: random access to an archive backed by a file descriptor, reading members lazily without retaining their content and writing new members in place; opened with open()/openSync(). - ZipBuffer: a zero-copy, in-memory view over an archive already held in a Buffer. createZipArchive() serializes a sequence of entries into an archive byte stream, and setMaxZipContentSize() bounds the default in-memory decompression size. Every operation has both an asynchronous and a synchronous form. Signed-off-by: Philipp Dunkel <pip@pipobscure.com> PR-URL: https://github.com/nodejs/node/pull/64339 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> Reviewed-By: Aviv Keller <me@aviv.sh>
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
// Archive serialization: `createZipArchive()`/`createZipArchiveSync()` and
|
|
// the `zipFiles()` on-disk variant, the `generateZipArchive()` generator they
|
|
// build on (auto-switching to Zip64 as offsets/counts overflow), and the
|
|
// shared archive-option normalizer.
|
|
|
|
const {
|
|
ArrayPrototypePush,
|
|
JSONStringify,
|
|
NumberMAX_SAFE_INTEGER,
|
|
StringPrototypeEndsWith,
|
|
SymbolAsyncDispose,
|
|
SymbolAsyncIterator,
|
|
SymbolDispose,
|
|
SymbolIterator,
|
|
} = primordials;
|
|
|
|
const {
|
|
codes: {
|
|
ERR_ZIP_ARCHIVE_TOO_LARGE,
|
|
ERR_ZIP_UNSUPPORTED_FEATURE,
|
|
},
|
|
} = require('internal/errors');
|
|
const {
|
|
validateBoolean,
|
|
validateInteger,
|
|
validateObject,
|
|
validateString,
|
|
} = require('internal/validators');
|
|
const { isUint8Array } = require('internal/util/types');
|
|
const { Buffer } = require('buffer');
|
|
const { Readable } = require('stream');
|
|
const fs = require('fs');
|
|
const {
|
|
EMPTY_BUFFER,
|
|
SENTINEL16,
|
|
kFinalize,
|
|
} = require('internal/zip/constants');
|
|
const {
|
|
buildArchiveTrailer,
|
|
} = require('internal/zip/header-builders');
|
|
const {
|
|
fsStatAsync,
|
|
fsLstatAsync,
|
|
fsReadlinkAsync,
|
|
} = require('internal/zip/fs-util');
|
|
const { ZipEntry } = require('internal/zip/entry');
|
|
|
|
/**
|
|
* `createZipArchive()`/`createZipArchiveSync()` (and the `ZipBuffer`
|
|
* `toBuffer()`/`toBufferSync()` methods that forward to them) take a single
|
|
* optional `options` argument that doubles as a plain archive comment: a
|
|
* string is shorthand for `{ comment: options }`.
|
|
* @param {string | { comment?: string, baseOffset?: number }} [options]
|
|
* @returns {{ comment: string | undefined, baseOffset: number }}
|
|
*/
|
|
function normalizeArchiveOptions(options) {
|
|
if (options === undefined) return { comment: undefined, baseOffset: 0 };
|
|
if (typeof options === 'string') return { comment: options, baseOffset: 0 };
|
|
validateObject(options, 'options');
|
|
const { comment, baseOffset = 0 } = options;
|
|
// A Buffer comment is an internal convenience (used when round-tripping an
|
|
// existing archive) that preserves the original bytes without forcing them
|
|
// through a decode/re-encode cycle that would corrupt non-UTF-8 comments.
|
|
if (comment !== undefined && !isUint8Array(comment)) {
|
|
validateString(comment, 'options.comment');
|
|
}
|
|
validateInteger(baseOffset, 'options.baseOffset', 0, NumberMAX_SAFE_INTEGER);
|
|
return { comment, baseOffset };
|
|
}
|
|
|
|
/**
|
|
* Serializes `entries` (a (async) iterable of `ZipEntry`) into a `Readable`
|
|
* stream of archive byte chunks, automatically switching to Zip64 structures
|
|
* once the entry count or any offset/size exceeds the classic 32-/16-bit
|
|
* limits.
|
|
*
|
|
* `options.baseOffset` shifts every local/central header offset the archive
|
|
* records by that many bytes, so the emitted bytes are self-describing even
|
|
* when something else is written before them - for example, appending the
|
|
* archive after `baseOffset` bytes already written to the same file, rather
|
|
* than at its start.
|
|
* @param {Iterable<ZipEntry> | AsyncIterable<ZipEntry>} entries
|
|
* @param {string | { comment?: string, baseOffset?: number }} [options]
|
|
* @returns {Readable}
|
|
*/
|
|
function createZipArchive(entries, options) {
|
|
return Readable.from(generateZipArchive(entries, options), { objectMode: false });
|
|
}
|
|
|
|
/**
|
|
* Creates an archive from files on disk. `files` is an iterable of
|
|
* `[sourcePath, entryName]` pairs - an array, a `Map`, the result of
|
|
* `Object.entries()`, a generator, and so on. Each entry captures the file's
|
|
* Unix mode and modification time; a directory becomes a directory entry and a
|
|
* regular file's contents are streamed in without being buffered in memory.
|
|
*
|
|
* With `options.followSymlinks` (default `true`) a symbolic link is resolved
|
|
* and archived as the file it points to; with it `false` the link itself is
|
|
* stored as a symlink entry whose content is the target path.
|
|
* @param {Iterable<[string, string]>} files
|
|
* @param {string | { followSymlinks?: boolean, comment?: string, baseOffset?: number }} [options]
|
|
* @returns {import('stream').Readable}
|
|
*/
|
|
function zipFiles(files, options) {
|
|
const followSymlinks = options?.followSymlinks ?? true;
|
|
validateBoolean(followSymlinks, 'options.followSymlinks');
|
|
return createZipArchive(fileEntries(files, followSymlinks), options);
|
|
}
|
|
|
|
// Turn each `[sourcePath, entryName]` pair into a `ZipEntry`, stat-ing the
|
|
// source to capture its mode/mtime and picking the symlink/directory/file
|
|
// entry shape; the file-backed variant streams contents rather than buffering.
|
|
async function* fileEntries(files, followSymlinks) {
|
|
for await (const pair of files) {
|
|
const sourcePath = pair[0];
|
|
const name = pair[1];
|
|
validateString(sourcePath, 'sourcePath');
|
|
validateString(name, 'name');
|
|
// Following links resolves through to the target (stat); otherwise the
|
|
// link itself is inspected (lstat) and stored as a symlink entry.
|
|
const stats = followSymlinks ? await fsStatAsync(sourcePath) : await fsLstatAsync(sourcePath);
|
|
const options = { __proto__: null, mode: stats.mode & 0o7777, modified: stats.mtime };
|
|
if (stats.isSymbolicLink()) {
|
|
yield ZipEntry.createSymlink(name, await fsReadlinkAsync(sourcePath), options);
|
|
} else if (stats.isDirectory()) {
|
|
const dirName = StringPrototypeEndsWith(name, '/') ? name : `${name}/`;
|
|
yield await ZipEntry.create(dirName, EMPTY_BUFFER, options);
|
|
} else {
|
|
// Open the file ourselves and stream from that exact descriptor instead
|
|
// of re-resolving the path with createReadStream(). Re-opening by path
|
|
// would let a symlink swapped in after the classification above redirect
|
|
// the read - a TOCTOU that defeats followSymlinks:false. O_NOFOLLOW makes
|
|
// a final-component symlink fail the open outright when not following;
|
|
// the fstat then confirms a regular file, never a FIFO, device, or
|
|
// socket (which as a stream source could block or emit unbounded data).
|
|
// Metadata comes from that same fstat so it describes the inode we read.
|
|
const flags = followSymlinks ?
|
|
fs.constants.O_RDONLY :
|
|
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
const handle = await fs.promises.open(sourcePath, flags);
|
|
let ownsHandle = true;
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile()) {
|
|
throw new ERR_ZIP_UNSUPPORTED_FEATURE(
|
|
`cannot archive ${JSONStringify(sourcePath)}: not a regular file`);
|
|
}
|
|
const fileOptions = {
|
|
__proto__: null,
|
|
mode: stat.mode & 0o7777,
|
|
modified: stat.mtime,
|
|
};
|
|
// The stream adopts the handle and closes it when it ends or is
|
|
// destroyed (autoClose); if the consumer stops before taking the
|
|
// entry, the finally destroys the stream, releasing the descriptor.
|
|
const stream = fs.createReadStream(undefined, { fd: handle });
|
|
ownsHandle = false;
|
|
let handedOff = false;
|
|
try {
|
|
yield ZipEntry.createStream(name, stream, fileOptions);
|
|
handedOff = true;
|
|
} finally {
|
|
if (!handedOff) stream.destroy();
|
|
}
|
|
} finally {
|
|
if (ownsHandle) await handle.close();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encode the archive comment (a string, or raw bytes when round-tripping a
|
|
// non-UTF-8 comment) and enforce the 16-bit length field (sec. 4.3.16).
|
|
function normalizeCommentBuffer(comment) {
|
|
if (comment === undefined) return EMPTY_BUFFER;
|
|
const buffer = isUint8Array(comment) ? comment : Buffer.from(comment, 'utf8');
|
|
if (buffer.length > SENTINEL16) {
|
|
throw new ERR_ZIP_ARCHIVE_TOO_LARGE(
|
|
'the archive comment must not exceed 65535 bytes when encoded as UTF-8');
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
// Dispose the entries still queued behind an interrupted serialization. A
|
|
// ZipEntry handed to the archive is owned by it, so when the consumer destroys
|
|
// the output stream early - which runs `generateZipArchive`'s `finally` - the
|
|
// entries it never reached must be released too (a streaming entry may hold a
|
|
// file read stream). The entry currently being serialized is torn down by the
|
|
// `for await ... of entry` loop's own return propagation, so only the queue
|
|
// behind it is handled here.
|
|
//
|
|
// A materialized source (an array, `Map`, `Object.entries()`) has already
|
|
// created every entry, so each remaining one is pulled and disposed. A lazy
|
|
// async source is instead `return()`ed: forcing it to produce every remaining
|
|
// entry could open thousands of descriptors just to close them, so it is
|
|
// signalled to stop and release its own in-flight resource (see
|
|
// `fileEntries()`).
|
|
async function disposeQueuedEntries(iterator, isAsync) {
|
|
if (isAsync) {
|
|
if (typeof iterator.return === 'function') await iterator.return();
|
|
return;
|
|
}
|
|
for (let next = iterator.next(); !next.done; next = iterator.next()) {
|
|
const entry = next.value;
|
|
if (typeof entry?.[SymbolAsyncDispose] === 'function') await entry[SymbolAsyncDispose]();
|
|
}
|
|
}
|
|
|
|
// Core serializer backing `createZipArchive()`: emits each entry's local header
|
|
// + data, then the central directory, then the end-of-central-directory record
|
|
// (APPNOTE sec. 4.3.6 archive layout), promoting to Zip64 end records
|
|
// (sec. 4.3.14/4.3.15) once any count/offset/size overflows its classic field.
|
|
//
|
|
// The entries are owned by this generator: it drives the iterator by hand
|
|
// (rather than `for await`) so that if the consumer destroys the returned
|
|
// stream partway - `Readable.from()` then calls this generator's `return()` -
|
|
// the `finally` can dispose every entry that was never fully serialized. A
|
|
// synchronous source is stepped without `await` so a pulled-but-not-yet-used
|
|
// entry cannot be stranded by a return injected at the `await`.
|
|
async function* generateZipArchive(entries, options) {
|
|
const { comment, baseOffset } = normalizeArchiveOptions(options);
|
|
const commentBuffer = normalizeCommentBuffer(comment);
|
|
const centralHeaders = [];
|
|
let pos = baseOffset;
|
|
const isAsync = entries[SymbolAsyncIterator] !== undefined;
|
|
const iterator = isAsync ? entries[SymbolAsyncIterator]() : entries[SymbolIterator]();
|
|
let completed = false;
|
|
try {
|
|
while (true) {
|
|
const next = isAsync ? await iterator.next() : iterator.next();
|
|
if (next.done) break;
|
|
const entry = next.value;
|
|
const start = pos;
|
|
for await (const chunk of entry) {
|
|
yield chunk;
|
|
pos += chunk.length;
|
|
}
|
|
ArrayPrototypePush(centralHeaders, entry[kFinalize](start));
|
|
}
|
|
const centralDirectoryOffset = pos;
|
|
for (let i = 0; i < centralHeaders.length; i++) {
|
|
const chunk = centralHeaders[i];
|
|
yield chunk;
|
|
pos += chunk.length;
|
|
}
|
|
const centralDirectorySize = pos - centralDirectoryOffset;
|
|
const count = centralHeaders.length;
|
|
const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer);
|
|
for (let i = 0; i < trailer.length; i++) yield trailer[i];
|
|
completed = true;
|
|
} finally {
|
|
if (!completed) await disposeQueuedEntries(iterator, isAsync);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The synchronous counterpart of `createZipArchive()`. `entries` must be a
|
|
* plain (synchronous) `Iterable` of entries that don't require an
|
|
* asynchronous serialization pass - a streaming entry created with
|
|
* `ZipEntry.createStream()` throws when its turn to serialize comes up, the
|
|
* same as calling `entry[Symbol.iterator]()` on one directly. Blocks the
|
|
* event loop and further JavaScript execution until the whole archive
|
|
* (including any deflate passes) has been produced; see
|
|
* `zipEntry.contentSync()`.
|
|
* @param {Iterable<ZipEntry>} entries
|
|
* @param {string | { comment?: string, baseOffset?: number }} [options]
|
|
* @yields {Buffer}
|
|
*/
|
|
function* createZipArchiveSync(entries, options) {
|
|
const { comment, baseOffset } = normalizeArchiveOptions(options);
|
|
const commentBuffer = normalizeCommentBuffer(comment);
|
|
const centralHeaders = [];
|
|
let pos = baseOffset;
|
|
const iterator = entries[SymbolIterator]();
|
|
let completed = false;
|
|
// As in generateZipArchive(), the entries are owned here: a streaming entry
|
|
// throws when serialized synchronously, so on that throw (or an early
|
|
// return of this generator) dispose the entry that failed and every entry
|
|
// still queued behind it, releasing any sources they hold.
|
|
let current = null;
|
|
try {
|
|
for (let next = iterator.next(); !next.done; next = iterator.next()) {
|
|
current = next.value;
|
|
const start = pos;
|
|
for (const chunk of current) {
|
|
yield chunk;
|
|
pos += chunk.length;
|
|
}
|
|
ArrayPrototypePush(centralHeaders, current[kFinalize](start));
|
|
current = null;
|
|
}
|
|
const centralDirectoryOffset = pos;
|
|
for (let i = 0; i < centralHeaders.length; i++) {
|
|
const chunk = centralHeaders[i];
|
|
yield chunk;
|
|
pos += chunk.length;
|
|
}
|
|
const centralDirectorySize = pos - centralDirectoryOffset;
|
|
const count = centralHeaders.length;
|
|
const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer);
|
|
for (let i = 0; i < trailer.length; i++) yield trailer[i];
|
|
completed = true;
|
|
} finally {
|
|
if (!completed) {
|
|
if (typeof current?.[SymbolDispose] === 'function') current[SymbolDispose]();
|
|
for (let next = iterator.next(); !next.done; next = iterator.next()) {
|
|
const entry = next.value;
|
|
if (typeof entry?.[SymbolDispose] === 'function') entry[SymbolDispose]();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeArchiveOptions,
|
|
createZipArchive,
|
|
createZipArchiveSync,
|
|
zipFiles,
|
|
};
|