'use strict';
// `ZipEntry`: a single archive member. Reads (buffered and streaming),
// (de)serialization, and the `create()`/`createSync()`/`createStream()`/
// `createSymlink()` builders, plus the `createEntryMeta()` helper that
// normalizes their options into the internal metadata record.
const {
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSort,
Date,
DateNow,
JSONStringify,
MathFloor,
MathMin,
NumberMAX_SAFE_INTEGER,
StringPrototypeEndsWith,
SymbolAsyncDispose,
SymbolAsyncIterator,
SymbolDispose,
SymbolIterator,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_STATE,
ERR_ZIP_ENTRY_TOO_LARGE,
ERR_ZIP_INVALID_ARCHIVE,
ERR_ZIP_UNSUPPORTED_FEATURE,
},
} = require('internal/errors');
const {
validateInteger,
validateString,
validateUint32,
} = require('internal/validators');
const {
isDate,
isUint8Array,
} = require('internal/util/types');
const { Buffer, kMaxLength } = require('buffer');
const { crc32: crc32Native } = internalBinding('zlib');
const {
EMPTY_BUFFER,
SIG_LOCAL_FILE_HEADER,
SENTINEL16,
FLAG_DATA_DESCRIPTOR,
FLAG_UTF8,
MADE_BY_UNIX,
METHOD_STORE,
METHOD_DEFLATE,
METHOD_ZSTD,
S_IFREG,
S_IFDIR,
S_IFLNK,
S_IFMT,
READ_CHUNK_SIZE,
kFinalize,
kPromote,
} = require('internal/zip/constants');
const {
toBuffer,
validateArchiveRange,
} = require('internal/zip/binary');
const {
extraFieldMtime,
stripZip64Extra,
} = require('internal/zip/extra-fields');
const {
decodeZipName,
decodeZipText,
} = require('internal/zip/dos');
const {
CentralFileHeader,
LocalFileHeader,
assertConsistentLocalHeader,
findArchiveEnd,
} = require('internal/zip/headers');
const {
buildLocalHeader,
buildCentralHeader,
buildDataDescriptor64,
} = require('internal/zip/header-builders');
const {
deflateRawAsync,
deflateRawSync,
zstdCompressAsync,
zstdCompressSync,
deflateRawStream,
zstdCompressStream,
decodeMemberStream,
decodeMemberAsync,
decodeMemberSync,
} = require('internal/zip/compression');
const {
readFdFully,
readFdFullySync,
} = require('internal/zip/fs-util');
const { getMaxZipContentSize } = require('internal/zip/content-size');
// The whole (UTC) second to record in an extended-timestamp extra field when
// `mtimeMs` cannot be represented exactly by the 2-second-resolution,
// local-time DOS date/time fields (sub-second parts and odd seconds alike),
// or null when the DOS fields suffice or the value does not fit the extra
// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared
// by createEntryMeta() and #finalizeMeta() so the two cannot drift.
function extendedMtimeSeconds(mtimeMs) {
const seconds = MathFloor(mtimeMs / 1000);
return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ?
seconds : null;
}
// Normalize the public builder options into the internal metadata record:
// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external
// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields.
function createEntryMeta(filename, options) {
validateString(filename, 'filename');
const name = Buffer.from(filename, 'utf8');
if (name.length === 0) {
throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty');
}
if (name.length > SENTINEL16) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
'the entry name must not exceed 65535 bytes when encoded as UTF-8');
}
let comment = EMPTY_BUFFER;
if (options?.comment !== undefined) {
validateString(options.comment, 'options.comment');
comment = Buffer.from(options.comment, 'utf8');
if (comment.length > SENTINEL16) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
'the entry comment must not exceed 65535 bytes when encoded as UTF-8');
}
}
const isSymlink = options?.symlink === true;
const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/');
const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644);
validateUint32(mode, 'options.mode');
// Default to the current time at the DOS fields' 2-second resolution, so a
// default entry needs no extended-timestamp extra field (see below).
const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000);
if (!isDate(modified)) {
throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified);
}
if (options?.method !== undefined &&
options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') {
throw new ERR_INVALID_ARG_VALUE(
'options.method', options.method, "must be 'deflate', 'store', or 'zstd'");
}
const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG;
const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16;
const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0;
// Record the whole (UTC) second in an extended-timestamp extra field when
// the DOS fields cannot represent the time exactly; see
// extendedMtimeSeconds().
const extendedMtime = extendedMtimeSeconds(modified.getTime());
return {
name,
comment,
extra: EMPTY_BUFFER,
flags: FLAG_UTF8,
method: 0,
crc: 0,
compressedSize: 0,
uncompressedSize: 0,
modified,
extendedMtime,
external,
internal: 0,
madeBy: MADE_BY_UNIX,
pending: true,
};
}
// Release one in-flight read on a shared ZipFile descriptor handle. When the
// last read settles, wake a close() that is waiting to release the fd (see
// ZipFile.close() in file.js), so a read never lands on a closed/reused fd.
function endHandleRead(handle) {
if (--handle.reads === 0 && handle.drain !== null) {
const drain = handle.drain;
handle.drain = null;
drain();
}
}
/**
* A single file or directory inside a ZIP archive: reading, writing, and
* (de)serializing one archive member.
*/
class ZipEntry {
#central;
#local;
#content;
#source = null;
#meta = null;
#serialized = false;
// When #fd is non-null the entry is "file-backed": it holds no content
// buffer, only a descriptor *handle* ({ fd, closed }) shared with the
// owning ZipFile plus the local-header offset, and reads its compressed
// bytes from disk on demand (see #compressedBytes/#rawChunks). The shared
// handle lets close() invalidate every outstanding entry at once, so a
// read never falls through to a bare (possibly reused) fd number.
// #contentOffset caches the resolved start of the compressed data (a
// number, not a buffer) once the local header has been read.
#fd = null;
#localOffset = -1;
#contentOffset = -1;
/**
* @private
*/
constructor(central, local, content, fd = null, localOffset = -1) {
this.#central = central;
this.#local = local;
this.#content = content;
this.#fd = fd;
this.#localOffset = localOffset;
}
// Whether the content is stored in compressed form, whatever the method.
get compressed() { return this.method !== METHOD_STORE; }
get method() {
return this.#meta ? this.#meta.method : this.#central.compressionMethod;
}
get flags() {
return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags;
}
get crc32() {
if (this.#meta) {
this.#assertNotPending();
return this.#meta.crc;
}
return this.#central.crc32;
}
get name() {
// The central directory is authoritative; a mismatched local-header name
// is deliberately ignored (defends against parser-confusion attacks).
// Both branches use the same decoding (Unicode Path extra, then
// UTF-8/CP437), so serialization - which snapshots the raw bytes into
// #meta - never changes what this getter reports.
return this.#meta ?
decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) :
this.#central.fileName;
}
get nameBuffer() {
return this.#meta ? this.#meta.name : this.#central.fileNameBuffer;
}
get comment() {
return this.#meta ?
decodeZipText(this.#meta.comment, this.#meta.flags) :
this.#central.fileComment;
}
get size() {
if (this.#meta) {
this.#assertNotPending();
return this.#meta.uncompressedSize;
}
return this.#central.uncompressedSize;
}
get compressedSize() {
if (this.#meta) {
this.#assertNotPending();
return this.#meta.compressedSize;
}
return this.#central.compressedSize;
}
get modified() {
if (this.#meta) return this.#meta.modified;
// Prefer an extra-field timestamp (absolute, higher-resolution) over the
// coarse local-time DOS date/time fields when a foreign archive carries
// one; consult both the central and local headers. A file-backed entry
// starts out with only its central header, but some tools (7-Zip, for
// one) write their high-fidelity timestamp only into the local header -
// resolve it lazily (a small, one-time positioned read) rather than
// silently reporting a coarser time than the archive carries, and fall
// back to the central data when the local header cannot be read.
if (this.#fd !== null && this.#local === null) {
try {
this.#resolveLocalHeaderSync();
} catch {
// A malformed local header fails loudly on the content read paths;
// for metadata, the central directory alone has to do.
}
}
return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ??
this.#central.lastModified;
}
get mode() {
// The external attributes' high 16 bits hold Unix permissions only when
// the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule
// as `CentralFileHeader.prototype.mode`, which the non-meta branch
// defers to.
if (this.#meta) {
return this.#meta.madeBy === MADE_BY_UNIX ?
(this.#meta.external >>> 16) & 0o7777 : 0;
}
return this.#central.mode;
}
get isSymlink() {
// Derived from the made-by host and the external attributes' Unix type
// bits in both branches, so it survives serialization (which preserves
// both verbatim); this also makes a fresh `createSymlink()` entry report
// itself as one.
if (this.#meta) {
return this.#meta.madeBy === MADE_BY_UNIX &&
((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK;
}
return this.#central.isSymlink;
}
get isFile() { return !this.isDirectory && !this.isSymlink; }
get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); }
// Guard: reject metadata/content reads on a write-streaming entry whose
// sizes and CRC are not yet known (still pending serialization).
#assertNotPending() {
if (this.#meta?.pending) {
throw new ERR_INVALID_STATE(
'this streaming entry has not finished serializing yet');
}
}
// Snapshot this (parsed) entry's central-directory data into a
// re-serializable #meta record, memoized. Needed so a round-tripped entry
// can be re-emitted from stable, extra-field-aware values rather than raw
// header bytes; clears the data-descriptor flag (see below).
#finalizeMeta() {
if (this.#meta) {
this.#assertNotPending();
return this.#meta;
}
const central = this.#central;
// Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit
// 3 cleared: full re-serialization emits a fresh local header from this
// same record, so it never reproduces a bit-3 local header without a
// data descriptor. (This invariant does NOT hold for `ZipFile`'s
// in-place central-directory rewrite, which leaves local headers on disk
// untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.)
// Sizes come from the central directory
// (Zip64-aware); Zip64 extras are regenerated as needed, and all other
// extra-field records (Unicode Path, NTFS/UT timestamps, ...) are
// preserved so a re-serialized entry keeps its name encoding and
// timestamps.
const extra = stripZip64Extra(central.extraField);
// Snapshot the resolved (extra-field-aware) time, not the raw DOS field,
// so serialization does not degrade what `modified` reports. When that
// time cannot be represented exactly in the DOS fields and no preserved
// extra record carries it, record it in an extended-timestamp extra
// (see extendedMtimeSeconds(); same rule as createEntryMeta()).
const modified = this.modified;
const extendedMtime = extraFieldMtime(extra) === null ?
extendedMtimeSeconds(modified.getTime()) : null;
const meta = {
name: central.fileNameBuffer,
comment: central.fileCommentBuffer,
extra,
flags: central.flags & ~FLAG_DATA_DESCRIPTOR,
method: central.compressionMethod,
crc: central.crc32,
compressedSize: central.compressedSize,
uncompressedSize: central.uncompressedSize,
modified,
extendedMtime,
external: central.externalFileAttributes,
internal: central.internalFileAttributes,
// Preserve the creator's host byte (sec. 4.4.2): the external
// attributes are only interpretable relative to it.
madeBy: central.version >>> 8,
pending: false,
};
this.#meta = meta;
return meta;
}
// The live numeric descriptor for a file-backed entry, or a clean state
// error if the ZipFile that owns it has since been closed - never a raw
// (possibly OS-reused) fd number. Returns null for an in-memory entry.
// A file-backed entry holds a descriptor *handle* ({ fd, closed }) shared
// with its ZipFile, not a bare fd, so close() is observable here.
#liveDescriptor() {
const handle = this.#fd;
if (handle === null) return null;
if (handle.closed) {
throw new ERR_INVALID_STATE(
'cannot read a ZipEntry after its backing ZipFile has been closed');
}
return handle.fd;
}
// Read (and cache) this file-backed entry's local file header - whose
// length (fixed 30 bytes plus variable name/extra fields) is only known
// from the file itself, not from the central directory. Resolving it also
// yields the offset where the compressed data begins, and gives the
// `modified` getter access to a local-header-only timestamp extra.
async #resolveLocalHeader() {
if (this.#local !== null) return this.#local;
const fd = this.#liveDescriptor();
const fixed = Buffer.allocUnsafe(30);
await readFdFully(fd, fixed, this.#localOffset);
if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
throw new ERR_ZIP_INVALID_ARCHIVE(
`entry ${JSONStringify(this.name)} has an invalid local file header`);
}
const length = LocalFileHeader.length(fixed, 0);
const full = Buffer.allocUnsafe(length);
fixed.copy(full, 0);
if (length > 30) {
await readFdFully(fd, full.subarray(30), this.#localOffset + 30);
}
const local = new LocalFileHeader(full, 0);
if (this.#central !== null) {
assertConsistentLocalHeader(local, this.#central);
}
const contentOffset = this.#localOffset + length;
this.#local = local;
this.#contentOffset = contentOffset;
return local;
}
// Sync counterpart of #resolveLocalHeader().
#resolveLocalHeaderSync() {
if (this.#local !== null) return this.#local;
const fd = this.#liveDescriptor();
const fixed = Buffer.allocUnsafe(30);
readFdFullySync(fd, fixed, this.#localOffset);
if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
throw new ERR_ZIP_INVALID_ARCHIVE(
`entry ${JSONStringify(this.name)} has an invalid local file header`);
}
const length = LocalFileHeader.length(fixed, 0);
const full = Buffer.allocUnsafe(length);
fixed.copy(full, 0);
if (length > 30) {
readFdFullySync(fd, full.subarray(30), this.#localOffset + 30);
}
const local = new LocalFileHeader(full, 0);
if (this.#central !== null) {
assertConsistentLocalHeader(local, this.#central);
}
const contentOffset = this.#localOffset + length;
this.#local = local;
this.#contentOffset = contentOffset;
return local;
}
// The offset where this file-backed entry's compressed data begins, reading
// the local header first if that has not been resolved yet.
async #resolveContentOffset() {
if (this.#contentOffset < 0) await this.#resolveLocalHeader();
return this.#contentOffset;
}
// Sync counterpart of #resolveContentOffset().
#resolveContentOffsetSync() {
if (this.#contentOffset < 0) this.#resolveLocalHeaderSync();
return this.#contentOffset;
}
// The in-memory raw bytes, or a clean state error when there are none - a
// write-streaming entry (`createStream()`) has no readable content until it
// has been serialized into a backing archive (after which `addEntry()`
// promotes it to file-backed; see [kPromote]).
#inMemoryCompressed() {
if (this.#content === null) {
throw new ERR_INVALID_STATE(
'the content of a streaming entry is not available for reading');
}
return this.#content;
}
// The entry's raw (still-compressed) bytes. For an in-memory entry this is
// the entry's retained buffer - shared memory, NOT a copy: it may alias
// the source archive (`ZipEntry.read()`) or the caller's original `data`
// (`create()` when storing). For a file-backed entry it is freshly read
// from disk and caller-owned. Paths that hand these bytes out undecoded
// (the store method) must copy the in-memory case; see `content()`.
async #compressedBytes() {
if (this.#fd === null) return this.#inMemoryCompressed();
const size = this.compressedSize;
// A member at or beyond the maximum Buffer length cannot be materialized
// in one allocation; stream it instead. (kMaxLength equals the safe
// integer ceiling on 64-bit, so a larger size fails to parse anyway.)
if (size >= kMaxLength) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
`entry ${JSONStringify(this.name)} is too large to buffer ` +
`(${size} compressed bytes); use contentIterator() instead`);
}
const start = await this.#resolveContentOffset();
const compressed = Buffer.allocUnsafe(size);
await readFdFully(this.#liveDescriptor(), compressed, start);
return compressed;
}
// Sync counterpart of #compressedBytes().
#compressedBytesSync() {
if (this.#fd === null) return this.#inMemoryCompressed();
const size = this.compressedSize;
if (size >= kMaxLength) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
`entry ${JSONStringify(this.name)} is too large to buffer ` +
`(${size} compressed bytes); use contentIterator() instead`);
}
const start = this.#resolveContentOffsetSync();
const compressed = Buffer.allocUnsafe(size);
readFdFullySync(this.#liveDescriptor(), compressed, start);
return compressed;
}
// The entry's raw compressed bytes as a bounded-memory chunk stream, read
// straight from disk (file-backed entries only). Nothing is retained.
async *#rawChunks() {
// Count active reads, but not time paused at a yield.
const handle = this.#fd;
if (handle.closing) {
throw new ERR_INVALID_STATE(
'cannot read a ZipEntry after its backing ZipFile has been closed');
}
handle.reads++;
let reading = true;
try {
this.#liveDescriptor();
let pos = await this.#resolveContentOffset();
let remaining = this.compressedSize;
while (remaining > 0) {
const take = MathMin(READ_CHUNK_SIZE, remaining);
const chunk = Buffer.allocUnsafe(take);
// Re-check per chunk: the ZipFile may be closed mid-stream.
await readFdFully(this.#liveDescriptor(), chunk, pos);
pos += take;
remaining -= take;
reading = false;
endHandleRead(handle);
yield chunk;
if (handle.closing) {
throw new ERR_INVALID_STATE(
'cannot read a ZipEntry after its backing ZipFile has been closed');
}
handle.reads++;
reading = true;
}
} finally {
if (reading) endHandleRead(handle);
}
}
// Sync counterpart of #rawChunks().
*#rawChunksSync() {
this.#liveDescriptor();
let pos = this.#resolveContentOffsetSync();
let remaining = this.compressedSize;
while (remaining > 0) {
const take = MathMin(READ_CHUNK_SIZE, remaining);
const chunk = Buffer.allocUnsafe(take);
// Re-check per chunk: the ZipFile may be closed mid-stream.
readFdFullySync(this.#liveDescriptor(), chunk, pos);
pos += take;
remaining -= take;
yield chunk;
}
}
/**
* Reads, decompresses, and (by default) CRC-32-verifies the whole entry
* into a single `Buffer`, enforcing the declared-size and `maxSize` limits.
* @param {{ verify?: boolean, maxSize?: number }} [options]
* @returns {Promise<Buffer>}
*/
async content(options) {
const declared = this.size;
const maxSize = options?.maxSize ?? getMaxZipContentSize();
if (declared > maxSize) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
`entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
`exceeding the ${maxSize} byte limit`);
}
// Count this read on the shared handle so a concurrent close() waits for it
// rather than releasing the fd mid-read; a read begun after close() was
// requested is rejected up front (in-memory entries have no fd).
const handle = this.#fd;
if (handle !== null) {
if (handle.closing) {
throw new ERR_INVALID_STATE(
'cannot read a ZipEntry after its backing ZipFile has been closed');
}
handle.reads++;
}
try {
const compressed = await this.#compressedBytes();
const data = await decodeMemberAsync(compressed, {
name: this.name,
flags: this.flags,
method: this.method,
crc32: this.crc32,
uncompressedSize: declared,
}, { verify: options?.verify, maxSize });
// `data === compressed` only on the store path; copy the in-memory case
// (the entry's retained buffer, see #compressedBytes()) so the result is
// caller-owned on every path.
return data === compressed && this.#fd === null ? Buffer.from(data) : data;
} finally {
if (handle !== null) endHandleRead(handle);
}
}
/**
* The synchronous counterpart of `content()`. Blocks the event loop and
* further JavaScript execution until the whole entry has been read and, if
* applicable, inflated - use only where synchronous I/O is appropriate
* (for example, short-lived scripts or startup code), not in code that
* must stay responsive.
* @param {{ verify?: boolean, maxSize?: number }} [options]
* @returns {Buffer}
*/
contentSync(options) {
const declared = this.size;
const maxSize = options?.maxSize ?? getMaxZipContentSize();
if (declared > maxSize) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
`entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
`exceeding the ${maxSize} byte limit`);
}
const compressed = this.#compressedBytesSync();
const data = decodeMemberSync(compressed, {
name: this.name,
flags: this.flags,
method: this.method,
crc32: this.crc32,
uncompressedSize: declared,
}, { verify: options?.verify, maxSize });
// `data === compressed` only on the store path; copy the in-memory case
// so the result is caller-owned on every path (see content()).
return data === compressed && this.#fd === null ? Buffer.from(data) : data;
}
// The raw (still-compressed) bytes as an async iterable, without buffering
// the whole member: straight from disk for a file-backed entry, or the
// single in-memory buffer otherwise. Throws synchronously for a pending
// write-streaming entry, whose content is not yet available for reading.
#rawSource() {
if (this.#fd !== null) return this.#rawChunks();
const content = this.#inMemoryCompressed();
return (async function* () {
if (content.length) yield content;
})();
}
/**
* Yields the entry's decompressed content as a bounded-memory async
* iterator of `Buffer` chunks, decompressing on the way and (by default)
* verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the
* compressed bytes are read from disk as they are consumed and nothing is
* retained.
* @param {{ verify?: boolean, maxSize?: number }} [options]
* @returns {AsyncGenerator<Buffer>}
*/
contentIterator(options) {
// No default maxSize here, unlike content(): streaming is the
// bounded-memory path for arbitrarily large members, so imposing content()'s
// buffer-oriented ceiling would defeat its purpose (and cap legitimate
// multi-gigabyte reads). Output is still bounded per chunk to the declared
// uncompressed size; a caller that wants an explicit cap passes
// options.maxSize.
return decodeMemberStream(this.#rawSource(), {
name: this.name,
flags: this.flags,
method: this.method,
crc32: this.crc32,
uncompressedSize: this.size,
}, options);
}
#assertRawWithin(maxSize) {
const declared = this.compressedSize;
if (declared > maxSize) {
throw new ERR_ZIP_ENTRY_TOO_LARGE(
`entry ${JSONStringify(this.name)} declares ${declared} compressed ` +
`bytes, exceeding the ${maxSize} byte limit`);
}
}
/**
* Reads the entry's raw (still-compressed) content into a single `Buffer`,
* without decompressing or CRC-32-verifying it.
* @param {{ maxSize?: number }} [options]
* @returns {Promise<Buffer>}
*/
async rawContent(options) {
this.#assertRawWithin(options?.maxSize ?? getMaxZipContentSize());
const handle = this.#fd;
if (handle === null) return Buffer.from(this.#inMemoryCompressed());
if (handle.closing) {
throw new ERR_INVALID_STATE(
'cannot read a ZipEntry after its backing ZipFile has been closed');
}
handle.reads++;
try {
return await this.#compressedBytes();
} finally {
endHandleRead(handle);
}
}
/**
* The synchronous counterpart of `rawContent()`.
* @param {{ maxSize?: number }} [options]
* @returns {Buffer}
*/
rawContentSync(options) {
this.#assertRawWithin(options?.maxSize ?? getMaxZipContentSize());
const compressed = this.#compressedBytesSync();
return this.#fd === null ? Buffer.from(compressed) : compressed;
}
/**
* Yields the entry's raw (still-compressed) content as a bounded-memory
* async iterator of `Buffer` chunks, without decompressing or verifying it.
* @param {{ maxSize?: number }} [options]
* @returns {AsyncGenerator<Buffer>}
*/
rawContentIterator(options) {
if (options?.maxSize !== undefined) this.#assertRawWithin(options.maxSize);
return this.#rawSource();
}
// Synchronous serialization: emit the local file header (sec. 4.3.7)
// followed by the raw (already-compressed) content bytes. Streaming
// (source-backed) entries cannot be serialized this way.
*[SymbolIterator]() {
if (this.#source) {
throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously');
}
const meta = this.#finalizeMeta();
yield buildLocalHeader(meta);
if (this.#fd !== null) {
yield* this.#rawChunksSync();
} else if (this.#content?.length) {
yield this.#content;
}
}
// Asynchronous serialization. For a write-streaming entry, drain the
// source once, computing CRC-32 and sizes on the fly and emitting a Zip64
// data descriptor after the content (sec. 4.3.9); otherwise defer to the
// buffered/file-backed paths.
async *[SymbolAsyncIterator]() {
const source = this.#source;
if (!source) {
if (this.#fd !== null) {
yield buildLocalHeader(this.#finalizeMeta());
yield* this.#rawChunks();
return;
}
yield* this[SymbolIterator]();
return;
}
if (this.#serialized) {
throw new ERR_INVALID_STATE('a streaming entry can only be serialized once');
}
this.#serialized = true;
const meta = this.#meta;
yield buildLocalHeader(meta);
let state = 0;
let uncompressedSize = 0;
let compressedSize = 0;
const counted = (async function* () {
for await (const chunk of source) {
if (!isUint8Array(chunk)) {
throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk);
}
if (!chunk.length) continue;
state = crc32Native(chunk, state);
uncompressedSize += chunk.length;
yield chunk;
}
})();
const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) :
meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted;
for await (const chunk of output) {
compressedSize += chunk.length;
yield chunk;
}
meta.crc = state;
meta.uncompressedSize = uncompressedSize;
meta.compressedSize = compressedSize;
meta.pending = false;
yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize);
}
// Release the entry's write-source, if it has one. Only a streaming entry
// (`createStream()`) owns a source - a caller-supplied `AsyncIterable`,
// often a file read stream holding a descriptor - and once that entry is
// handed to `createZipArchive()` the caller can no longer reach it, so the
// archive machinery disposes it when the archive finishes or its output
// stream is destroyed early (see `generateZipArchive()`); a caller holding
// an unused streaming entry can dispose it directly. In-memory and
// file-backed entries hold no source - a file-backed entry's descriptor
// belongs to the `ZipFile`, never to the entry - so disposing them is a
// no-op. Disposal is idempotent and marks the entry spent, so a disposed
// streaming entry can no longer be serialized.
#releaseSource() {
const source = this.#source;
this.#source = null;
this.#serialized = true;
return source;
}
[SymbolDispose]() {
const source = this.#releaseSource();
if (source === null) return;
if (typeof source.destroy === 'function') source.destroy();
else if (typeof source.return === 'function') source.return();
}
async [SymbolAsyncDispose]() {
const source = this.#releaseSource();
if (source === null) return;
if (typeof source[SymbolAsyncDispose] === 'function') await source[SymbolAsyncDispose]();
else if (typeof source.destroy === 'function') source.destroy();
else if (typeof source.return === 'function') await source.return();
}
/**
* Builds this entry's central-directory header (sec. 4.3.12) recording its
* local-header start; called by the archive writer once the offset is fixed.
*
* `preserveDescriptorFlag` is set by `ZipFile`'s in-place
* central-directory rewrite, which never regenerates local headers: when
* the on-disk local header advertises a data descriptor (bit 3, which
* `#finalizeMeta()` clears for full re-serialization), the rebuilt central
* header must keep advertising it too, or the two headers would contradict
* each other (sec. 4.3.12 expects them to agree). Full re-serialization
* emits a fresh, bit-3-free local header from the same meta, so there the
* cleared flag is the consistent one.
* @private
* @param {number} localOffset
* @param {boolean} [preserveDescriptorFlag]
* @returns {Buffer}
*/
[kFinalize](localOffset, preserveDescriptorFlag = false) {
validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER);
const meta = this.#finalizeMeta();
if (preserveDescriptorFlag && this.#central !== null &&
(this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) {
return buildCentralHeader(
{ ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset);
}
return buildCentralHeader(meta, localOffset);
}
// Rebind a just-serialized write-streaming entry to its on-disk copy so it
// stops being dead weight: after `addEntry()`/`addEntrySync()` writes the
// entry into `fd` at `localOffset` (its local-header start), the spent
// source is dropped and the entry becomes a readable, re-serializable
// file-backed entry (valid while `fd` stays open). Only a spent stream
// entry - one with neither an in-memory buffer nor an existing backing fd -
// is promoted; in-memory and already-file-backed entries are left as they
// are. The kept `#meta` still supplies the (now-final) name/sizes/crc.
[kPromote](fd, localOffset) {
if (this.#content !== null || this.#fd !== null) return;
this.#fd = fd;
this.#localOffset = localOffset;
this.#contentOffset = -1;
this.#source = null;
this.#serialized = false;
// The descriptor flag has served its purpose: the sizes and CRC are
// known now, and the fd-backed serialization path emits plain headers
// and never writes a descriptor. Left set, a re-serialization would
// advertise (bit 3) a data descriptor that never follows - a corrupt
// archive for any reader that honors the flag.
this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR;
}
/**
* Parses an in-memory archive, walking the central directory (sec. 4.3.12)
* and each referenced local header (sec. 4.3.7), and yields one read-only
* `ZipEntry` per member.
* @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer
* @yields {ZipEntry}
*/
static *read(buffer) {
const buf = toBuffer(buffer, 'buffer');
yield* readArchiveEntries(buf, findArchiveEnd(buf));
}
/**
* Builds a ready-to-serialize entry from in-memory `data`, compressing it
* with the chosen method (falling back to store when that does not shrink
* it) and recording the CRC-32 and sizes. When the entry ends up stored
* (explicitly or via the fallback) it retains `data`'s memory rather than
* copying it, and the CRC-32 is recorded now - mutating `data` afterwards
* would corrupt the entry on write.
* @param {string} filename
* @param {Buffer | TypedArray | DataView | ArrayBuffer} data
* @param {{
* comment?: string,
* mode?: number,
* modified?: Date,
* method?: 'deflate' | 'store' | 'zstd',
* }} [options]
* @returns {Promise<ZipEntry>}
*/
// Shared head of create()/createSync(): validate, snapshot the CRC-32 and
// uncompressed size, and pick the compression method (store is forced for
// directories and empty content). The compression pass itself is the only
// thing the async/sync builders do differently.
static #prepareCreate(filename, data, options) {
const meta = createEntryMeta(filename, options);
const content = toBuffer(data, 'data');
const isDirectory = StringPrototypeEndsWith(filename, '/');
if (isDirectory && content.length) {
throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry');
}
meta.crc = crc32Native(content, 0);
meta.uncompressedSize = content.length;
const method =
isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE :
options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
return { meta, content, method };
}
// Shared tail of create()/createSync(): keep the compressed bytes only
// when the pass actually shrank the content (otherwise store the original,
// which also retains the caller's memory - see create()'s JSDoc), fill in
// the final sizes, and construct the entry. `compressed` is null when
// `method` is store.
static #finishCreate(meta, content, method, compressed) {
let finalContent = content;
if (compressed !== null && compressed.length < content.length) {
finalContent = compressed;
} else if (compressed !== null) {
method = METHOD_STORE; // Compression did not help; fall back to storing
}
meta.method = method;
meta.compressedSize = finalContent.length;
meta.pending = false;
const entry = new ZipEntry(null, null, finalContent);
entry.#meta = meta;
return entry;
}
static async create(filename, data, options) {
const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
const compressed =
method === METHOD_DEFLATE ? await deflateRawAsync(content) :
method === METHOD_ZSTD ? await zstdCompressAsync(content) : null;
return ZipEntry.#finishCreate(meta, content, method, compressed);
}
/**
* The synchronous counterpart of `create()`. Blocks the event loop and
* further JavaScript execution until done (including the deflate pass);
* see `contentSync()`. Retains `data`'s memory when storing, same as
* `create()`.
* @param {string} filename
* @param {Buffer | TypedArray | DataView | ArrayBuffer} data
* @param {{
* comment?: string,
* mode?: number,
* modified?: Date,
* method?: 'deflate' | 'store' | 'zstd',
* }} [options]
* @returns {ZipEntry}
*/
static createSync(filename, data, options) {
const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
const compressed =
method === METHOD_DEFLATE ? deflateRawSync(content) :
method === METHOD_ZSTD ? zstdCompressSync(content) : null;
return ZipEntry.#finishCreate(meta, content, method, compressed);
}
/**
* Builds a write-streaming entry whose content is drained from `source`
* only at serialization time; its sizes/CRC are unknown until then, so it
* sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending.
* @param {string} filename
* @param {AsyncIterable<Buffer>} source
* @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
* @returns {ZipEntry}
*/
static createStream(filename, source, options) {
const meta = createEntryMeta(filename, options);
if (StringPrototypeEndsWith(filename, '/')) {
throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed');
}
meta.flags |= FLAG_DATA_DESCRIPTOR;
meta.method = options?.method === 'store' ? METHOD_STORE :
options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
meta.pending = true;
const entry = new ZipEntry(null, null, null);
entry.#meta = meta;
entry.#source = source;
return entry;
}
/**
* Creates a symbolic-link entry: a stored entry whose content is the link
* target and whose Unix mode type bits are `S_IFLNK`.
* @param {string} filename
* @param {string} target The link target path.
* @param {{ comment?: string, mode?: number, modified?: Date }} [options]
* @returns {ZipEntry}
*/
static createSymlink(filename, target, options) {
validateString(target, 'target');
const meta = createEntryMeta(filename, {
__proto__: null,
comment: options?.comment,
mode: options?.mode,
modified: options?.modified,
symlink: true,
});
const content = Buffer.from(target, 'utf8');
meta.crc = crc32Native(content, 0);
meta.uncompressedSize = content.length;
meta.method = METHOD_STORE;
meta.compressedSize = content.length;
meta.pending = false;
const entry = new ZipEntry(null, null, content);
entry.#meta = meta;
return entry;
}
}
// Walk the central directory described by `end` (a findArchiveEnd() result
// for `buf`), yielding one read-only ZipEntry per member. Split from
// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record
// for its comment - can locate the archive end once and share the result
// instead of scanning for it twice. All records are parsed and their member
// ranges cross-checked before the first entry is yielded: members must be
// disjoint and precede the central directory, or one small file could quote
// the same data region from N records - the "quoted overlap" zip-bomb shape
// (CVE-2024-0450 in other implementations). Here the local headers have
// been read, so the check uses each member's exact end (`ZipFile` applies
// the same rule with a lower bound; see `validateMemberBounds()`).
function* readArchiveEntries(buf, end) {
let pos = end.centralDirectoryOffset;
const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize;
const parsed = [];
for (let index = 0; index < end.totalRecords; index++) {
const central = new CentralFileHeader(buf, pos);
if (pos + central.byteLength > cdEnd) {
throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds');
}
if (central.diskNumber !== 0) {
throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
}
const localOffset = central.localFileHeaderOffset + end.prefix;
const local = new LocalFileHeader(buf, localOffset);
assertConsistentLocalHeader(local, central);
const dataStart = localOffset + local.byteLength;
const length = central.compressedSize;
validateArchiveRange(buf, dataStart, length, 'entry data');
const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER;
ArrayPrototypePush(parsed, {
entry: new ZipEntry(central, local, content),
start: localOffset,
dataEnd: dataStart + length,
});
pos = central.byteOffset + central.byteLength;
}
if (pos !== cdEnd) {
throw new ERR_ZIP_INVALID_ARCHIVE(
'central directory record count is inconsistent with its size');
}
// Sort a separate range list; entries themselves are yielded in central
// directory order.
const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start);
for (let i = 0; i < ranges.length; i++) {
const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset;
if (ranges[i].dataEnd > bound) {
throw new ERR_ZIP_INVALID_ARCHIVE(
`entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` +
'entry or the central directory (possible zip bomb)');
}
}
for (let i = 0; i < parsed.length; i++) yield parsed[i].entry;
}
module.exports = {
createEntryMeta,
readArchiveEntries,
ZipEntry,
};