'use strict';
// `ZipFile`: random-access, in-place-writable view over an archive on disk
// (a raw fd), plus `buildCentralDirectoryChunks()` which rebuilds the central
// directory (and its Zip64/EOCD trailer) for the in-place rewrite path.
const {
ArrayPrototypePush,
ArrayPrototypeSort,
FunctionPrototypeCall,
JSONStringify,
Map,
MapPrototypeClear,
MapPrototypeDelete,
MapPrototypeEntries,
MapPrototypeGet,
MapPrototypeGetSize,
MapPrototypeHas,
MapPrototypeKeys,
MapPrototypeSet,
MathMin,
Promise,
PromisePrototypeThen,
PromiseResolve,
SymbolAsyncDispose,
SymbolAsyncIterator,
SymbolDispose,
SymbolIterator,
SymbolToStringTag,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_TYPE,
ERR_INVALID_STATE,
ERR_ZIP_ARCHIVE_TOO_LARGE,
ERR_ZIP_ENTRY_NOT_FOUND,
ERR_ZIP_INVALID_ARCHIVE,
ERR_ZIP_NOT_WRITABLE,
},
} = require('internal/errors');
const {
validateBoolean,
validateFunction,
validateString,
} = require('internal/validators');
const { Buffer, kMaxLength } = require('buffer');
const { Readable } = require('stream');
const fs = require('fs');
const {
SIG_LOCAL_FILE_HEADER,
TAIL_LENGTH,
kFinalize,
kPromote,
} = require('internal/zip/constants');
const {
buildArchiveTrailer,
} = require('internal/zip/header-builders');
const {
CentralFileHeader,
LocalFileHeader,
findArchiveEnd,
readCentralDirectory,
} = require('internal/zip/headers');
const { decodeZipText } = require('internal/zip/dos');
const {
fsOpenAsync,
fsCloseAsync,
fsFstatAsync,
fsFtruncateAsync,
readFdFully,
readFdFullySync,
writeFdFully,
writeFdFullySync,
} = require('internal/zip/fs-util');
const { ZipEntry } = require('internal/zip/entry');
const {
createZipArchive,
createZipArchiveSync,
} = require('internal/zip/archive');
/**
* Builds a fresh central directory (sec. 4.3.12) plus its trailer (the Zip64
* end record/locator when needed and the end-of-central-directory record;
* see `buildArchiveTrailer()`) for `records`, an array of
* `{ entry, localOffset }` pairs already in their final order and at their
* final (possibly pre-existing, possibly freshly written) offsets.
* @param {Array<{ entry: ZipEntry, localOffset: number }>} records
* @param {number} centralDirectoryOffset
* @param {Buffer} comment
* @returns {{ centralHeaders: Buffer[], chunks: Buffer[] }}
*/
function buildCentralDirectoryChunks(records, centralDirectoryOffset, comment) {
const centralHeaders = [];
let centralDirectorySize = 0;
for (let i = 0; i < records.length; i++) {
// `true`: this rewrite leaves local headers on disk untouched, so an
// entry whose local header advertises a data descriptor (bit 3) must
// keep advertising it in the rebuilt central header; see [kFinalize].
const header = records[i].entry[kFinalize](records[i].localOffset, true);
ArrayPrototypePush(centralHeaders, header);
centralDirectorySize += header.length;
}
const count = records.length;
const chunks = [];
for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]);
const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment);
for (let i = 0; i < trailer.length; i++) ArrayPrototypePush(chunks, trailer[i]);
return { centralHeaders, chunks };
}
// Shared post-location validation for open()/openSync(): the archive tail
// has been found; make sure the central directory it describes can actually
// be buffered and lies inside the file.
function checkArchiveEnd(end, size) {
if (end.centralDirectorySize > kMaxLength) {
throw new ERR_ZIP_ARCHIVE_TOO_LARGE('the central directory is too large to buffer');
}
if (end.centralDirectoryOffset + end.centralDirectorySize > size) {
throw new ERR_ZIP_INVALID_ARCHIVE('central directory is out of bounds');
}
}
// Given the fixed 30-byte local header read at `offset`, return where the
// member's data ends. The data starts after the full local header (fixed +
// file name + extra field), whose length lives only in the local header, not
// the central directory - so the exact end can only be measured by reading it.
// A malformed local header (bad signature) is not rejected here: fall back to
// the 30-byte minimum so the archive stays openable (a read of that entry will
// surface the error) while the member is still bounded by a safe lower bound.
// Well-formed headers - the case an overlap attack must use to be readable -
// are measured exactly, matching the read path and the in-memory reader.
function localHeaderEnd(fixed, offset, compressedSize) {
if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
return offset + 30 + compressedSize;
}
return offset + LocalFileHeader.length(fixed, 0) + compressedSize;
}
// Reject any member whose actual compressed bytes cannot lie inside the file,
// and any pair of members whose data ranges overlap each other or the central
// directory. The buffered read paths (`ZipEntry.prototype.content()` and
// friends) allocate `compressedSize` bytes before reading, so without the
// bounds check a tiny file whose central directory lies about a member's size
// could force an allocation of up to `kMaxLength` bytes. The overlap check
// counters the "quoted overlap" zip-bomb shape (CVE-2024-0450 in other
// implementations): N records quoting the same data region turn one small file
// into N full-size extractions, while real archives lay members out
// disjointly. The exact data range depends on the local header's length, which
// is read here (one small read per member) so the check matches the read path
// and the in-memory reader (`readArchiveEntries()`) rather than trusting a
// looser lower bound.
async function validateMemberBounds(fd, headers, prefix, size, centralDirectoryOffset) {
const members = [];
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
const offset = header.localFileHeaderOffset + prefix;
const fixed = Buffer.allocUnsafe(30);
await readFdFully(fd, fixed, offset);
const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize);
checkMemberRange(members, header.fileName, offset, dataEnd, size);
}
checkMemberOverlap(members, centralDirectoryOffset);
}
// Sync counterpart of validateMemberBounds().
function validateMemberBoundsSync(fd, headers, prefix, size, centralDirectoryOffset) {
const members = [];
for (let i = 0; i < headers.length; i++) {
const header = headers[i];
const offset = header.localFileHeaderOffset + prefix;
const fixed = Buffer.allocUnsafe(30);
readFdFullySync(fd, fixed, offset);
const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize);
checkMemberRange(members, header.fileName, offset, dataEnd, size);
}
checkMemberOverlap(members, centralDirectoryOffset);
}
// Record a member's [offset, dataEnd) range, rejecting one that runs past the
// end of the file.
function checkMemberRange(members, fileName, offset, dataEnd, size) {
if (dataEnd > size) {
throw new ERR_ZIP_INVALID_ARCHIVE(
`entry ${JSONStringify(fileName)} data is out of bounds`);
}
ArrayPrototypePush(members, { fileName, offset, dataEnd });
}
// Sorted by local-header start, reject any member whose data runs into the
// next member's local header or into the central directory.
function checkMemberOverlap(members, centralDirectoryOffset) {
ArrayPrototypeSort(members, (a, b) => a.offset - b.offset);
for (let i = 0; i < members.length; i++) {
const bound = i + 1 < members.length ? members[i + 1].offset : centralDirectoryOffset;
if (members[i].dataEnd > bound) {
throw new ERR_ZIP_INVALID_ARCHIVE(
`entry ${JSONStringify(members[i].fileName)} overlaps the next ` +
'entry or the central directory (possible zip bomb)');
}
}
}
/**
* A random-access view over the entries of a ZIP archive on disk. Only the
* archive tail and central directory are read up front; individual member
* content is read lazily and on demand. Writable when opened with
* `{ writable: true }`: adding or deleting an entry rewrites the central
* directory in place, appending new entry content where the old central
* directory used to be.
*
* Every method has a `*Sync` counterpart. The synchronous methods block the
* Node.js event loop and further JavaScript execution until the operation
* completes - use them only where synchronous I/O is appropriate (for
* example, short-lived scripts or startup code), never in code that must
* stay responsive. A synchronous method throws `ERR_INVALID_STATE` if called
* while an asynchronous `addEntry()`/`add()`/`delete()`/`close()` on the same
* `ZipFile` has not settled yet, since letting the two interleave could
* corrupt the archive.
*/
class ZipFile {
// Shared descriptor handle ({ fd, closed, reads, drain }) handed to every
// ZipEntry this archive produces, so close() can invalidate them all at once.
// `#closing` is set synchronously the moment close()/closeSync() is called,
// gating any further public call; `handle.closed` then stops new reads, and
// `handle.reads` counts in-flight reads through already-handed-out entries so
// close() can wait for them to finish before releasing the fd. Neither a new
// nor an in-flight read ever falls through to a closed (possibly OS-reused)
// descriptor number.
#handle;
#closing = false;
#closePromise = null;
#writable;
#comment;
#centralDirectoryOffset;
#entries = new Map();
#queue = PromiseResolve();
#pendingAsyncOps = 0;
/**
* Builds the by-name entry map from the already-parsed central headers,
* recording each member's local-header offset (adjusted by any prefix).
* @private
*/
constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) {
this.#handle = { fd, closed: false, closing: false, reads: 0, drain: null };
this.#writable = writable;
this.#comment = comment;
this.#centralDirectoryOffset = centralDirectoryOffset;
for (let i = 0; i < centralHeaders.length; i++) {
const central = centralHeaders[i];
MapPrototypeSet(this.#entries, central.fileName, {
central,
entry: undefined,
localOffset: central.localFileHeaderOffset + prefix,
});
}
}
get writable() { return this.#writable; }
// The EOCD comment has no encoding flag; apply the same UTF-8/CP437
// heuristic as unflagged member names and comments.
get comment() { return decodeZipText(this.#comment, 0); }
// Guard: throw unless this archive was opened writable.
#assertWritable() {
if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE();
}
// Guard: reject any operation once close() has been initiated, so a
// request never reaches a closed (or reused) descriptor. Set synchronously
// by close()/closeSync() so an add() issued after them cannot slip through.
#assertOpen() {
if (this.#closing) {
throw new ERR_INVALID_STATE('the ZipFile has been closed');
}
}
// Guard: reject a synchronous call while an async mutation is still in
// flight, since interleaving the two could corrupt the archive.
#assertNotBusy() {
if (this.#pendingAsyncOps > 0) {
throw new ERR_INVALID_STATE(
'cannot call a synchronous ZipFile method while an asynchronous ' +
'add(), addEntry(), delete(), or close() call has not settled yet');
}
}
// Serialize async mutations (add/delete/close) through a promise chain so
// they never overlap and corrupt the archive; the in-flight counter backs
// #assertNotBusy(). Failures do not break the chain (both arms continue it).
#enqueue(fn) {
this.#pendingAsyncOps++;
const run = async () => {
try {
return await fn();
} finally {
this.#pendingAsyncOps--;
}
};
const result = PromisePrototypeThen(this.#queue, run, run);
this.#queue = PromisePrototypeThen(result, () => undefined, () => undefined);
return result;
}
has(name) {
this.#assertOpen();
validateString(name, 'name');
return MapPrototypeHas(this.#entries, name);
}
// Return the lazy, file-backed ZipEntry handle for `info`, creating and
// caching it on first access. The handle stores only a descriptor and the
// local-header offset - never the member's content - so repeated `get()`s
// return the same lightweight object and no content buffer is retained by
// the ZipFile. Any read (`content()`, `contentIterator()`) goes to disk.
#handleFor(info) {
info.entry ??= new ZipEntry(info.central, null, null, this.#handle, info.localOffset);
return info.entry;
}
/**
* Returns a lazy, file-backed `ZipEntry` for `name`. Nothing is read from
* disk here and no content is buffered; the entry reads (and, for
* `content()`, decompresses) straight from the file on each access. The
* returned entry is valid only while this `ZipFile` is open.
* @param {string} name
* @returns {Promise<ZipEntry>}
*/
async get(name) {
this.#assertOpen();
validateString(name, 'name');
const info = MapPrototypeGet(this.#entries, name);
if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
return this.#handleFor(info);
}
/**
* The synchronous counterpart of `get()`. Like `get()`, it reads nothing
* up front and buffers no content - it only builds the lazy handle - so it
* does not itself block on I/O; see the class-level note on synchronous
* methods for reads performed later through the returned entry.
* @param {string} name
* @returns {ZipEntry}
*/
getSync(name) {
this.#assertOpen();
this.#assertNotBusy();
validateString(name, 'name');
const info = MapPrototypeGet(this.#entries, name);
if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
return this.#handleFor(info);
}
/**
* Streams a member's decoded content without buffering the whole member,
* as a `Readable` (verifying CRC-32 by default; `{ verify: false }` to opt
* out). Sugar for wrapping `get(name).contentIterator(options)`; the
* compressed bytes are read from disk as the stream is consumed.
* @param {string} name
* @param {{ verify?: boolean, maxSize?: number }} [options]
* @returns {Promise<import('stream').Readable>}
*/
async stream(name, options) {
const entry = await this.get(name);
return Readable.from(entry.contentIterator(options), { objectMode: false });
}
/**
* Writes `entry`'s serialized bytes where the central directory currently
* starts, then rewrites the central directory to include it. Replaces any
* existing entry of the same name (its bytes become dead space, reclaimed
* by `compact()`).
* @param {ZipEntry} entry
* @returns {Promise<ZipEntry>}
*/
async addEntry(entry) {
this.#assertWritable();
this.#assertOpen();
if (!(entry instanceof ZipEntry)) {
throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
}
return this.#enqueue(() => this.#doAdd(entry));
}
/**
* Builds an entry from in-memory `data` and appends it; see `addEntry()`.
* @param {string} filename
* @param {Buffer | TypedArray | DataView | ArrayBuffer} data
* @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
* @returns {Promise<ZipEntry>}
*/
async add(filename, data, options) {
this.#assertWritable();
this.#assertOpen();
// Reserve the mutation synchronously (before the async ZipEntry.create()),
// so a close()/closeSync() issued right after cannot slip in front of it
// and tear down the descriptor this write depends on.
return this.#enqueue(async () =>
this.#doAdd(await ZipEntry.create(filename, data, options)));
}
// Append the entry's bytes where the central directory currently starts, then
// rewrite the directory to include it. If the member write or the directory
// rewrite fails, restore the original directory and drop the half-adopted
// entry so the archive is left exactly as it was, then rethrow; on success,
// promote a spent stream entry to its on-disk copy.
async #doAdd(entry) {
const localOffset = this.#centralDirectoryOffset;
let written = 0;
try {
for await (const chunk of entry) {
await writeFdFully(this.#handle.fd, chunk, localOffset + written);
written += chunk.length;
}
} catch (err) {
// The failed entry's bytes start where the old central directory
// started, so part of it may already be overwritten - while the EOCD
// still points at it. Nothing has been adopted into memory yet, so
// rebuild and rewrite the directory (and EOCD) at its original offset
// to leave the archive exactly as it was before the call.
try {
await this.#rewriteCentralDirectory();
} catch {
// Restoring failed too (the device is likely full or gone); the
// original error is the actionable one.
}
throw err;
}
const previousEntry = MapPrototypeGet(this.#entries, entry.name);
this.#centralDirectoryOffset = localOffset + written;
MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset });
try {
await this.#rewriteCentralDirectory();
} catch (err) {
// The directory rewrite failed after the member bytes landed. Undo the
// in-memory adoption and rewrite the original directory back at its old
// offset (where the failed member bytes started), leaving the archive and
// this handle exactly as before the call.
this.#centralDirectoryOffset = localOffset;
if (previousEntry === undefined) {
MapPrototypeDelete(this.#entries, entry.name);
} else {
MapPrototypeSet(this.#entries, entry.name, previousEntry);
}
try {
await this.#rewriteCentralDirectory();
} catch {
// Restoring failed too (the device is likely full or gone); the
// original error is the actionable one.
}
throw err;
}
// The entry now has a stable home in this archive; if it was a spent
// streaming entry, rebind it to that on-disk copy so it stays readable.
entry[kPromote](this.#handle, localOffset);
return entry;
}
/**
* The synchronous counterpart of `addEntry()`. `entry` must not be a
* pending streaming entry (one created with `ZipEntry.createStream()`) -
* there is no synchronous way to drain its asynchronous source. Blocks the
* event loop until done; see the class-level note on synchronous methods.
* @param {ZipEntry} entry
* @returns {ZipEntry}
*/
addEntrySync(entry) {
this.#assertWritable();
this.#assertOpen();
this.#assertNotBusy();
if (!(entry instanceof ZipEntry)) {
throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
}
const localOffset = this.#centralDirectoryOffset;
let written = 0;
try {
for (const chunk of entry) {
writeFdFullySync(this.#handle.fd, chunk, localOffset + written);
written += chunk.length;
}
} catch (err) {
// See #doAdd(): restore the (partially overwritten) central directory
// before surfacing the failure.
try {
this.#rewriteCentralDirectorySync();
} catch {
// Restoring failed too; the original error is the actionable one.
}
throw err;
}
const previousEntry = MapPrototypeGet(this.#entries, entry.name);
this.#centralDirectoryOffset = localOffset + written;
MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset });
try {
this.#rewriteCentralDirectorySync();
} catch (err) {
// See #doAdd(): undo the in-memory adoption and restore the original
// directory so a failed rewrite leaves the archive exactly as it was.
this.#centralDirectoryOffset = localOffset;
if (previousEntry === undefined) {
MapPrototypeDelete(this.#entries, entry.name);
} else {
MapPrototypeSet(this.#entries, entry.name, previousEntry);
}
try {
this.#rewriteCentralDirectorySync();
} catch {
// Restoring failed too; the original error is the actionable one.
}
throw err;
}
entry[kPromote](this.#handle, localOffset);
return entry;
}
/**
* The synchronous counterpart of `add()`. Blocks the event loop until
* done (including the deflate pass); see the class-level note on
* synchronous methods.
* @param {string} filename
* @param {Buffer | TypedArray | DataView | ArrayBuffer} data
* @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
* @returns {ZipEntry}
*/
addSync(filename, data, options) {
this.#assertWritable();
return this.addEntrySync(ZipEntry.createSync(filename, data, options));
}
/**
* Removes an entry by name. The central directory is rewritten in place
* (no new content is written, so the archive does not grow); the removed
* entry's bytes become dead space, reclaimed by `compact()`.
* @param {string} name
* @returns {Promise<boolean>}
*/
async delete(name) {
this.#assertWritable();
this.#assertOpen();
validateString(name, 'name');
return this.#enqueue(() => this.#doDelete(name));
}
// Drop the named entry and rewrite the central directory (no member bytes
// move, so the file does not grow); reports whether it existed.
async #doDelete(name) {
const existed = MapPrototypeDelete(this.#entries, name);
if (existed) await this.#rewriteCentralDirectory();
return existed;
}
/**
* The synchronous counterpart of `delete()`. Blocks the event loop until
* done; see the class-level note on synchronous methods.
* @param {string} name
* @returns {boolean}
*/
deleteSync(name) {
this.#assertWritable();
this.#assertOpen();
this.#assertNotBusy();
validateString(name, 'name');
const existed = MapPrototypeDelete(this.#entries, name);
if (existed) this.#rewriteCentralDirectorySync();
return existed;
}
// Snapshot the live entries as ordered { entry, localOffset } records for a
// central-directory rebuild, materializing a plain ZipEntry for any member
// not yet handed out as a lazy handle.
#liveRecords() {
const records = [];
const names = [];
for (const { 0: name, 1: value } of MapPrototypeEntries(this.#entries)) {
ArrayPrototypePush(records, {
entry: value.entry ?? new ZipEntry(value.central, null, null),
localOffset: value.localOffset,
});
ArrayPrototypePush(names, name);
}
return { records, names };
}
// Rebuild the central directory and its trailer (sec. 4.3.12/4.3.16) for the
// current live set and overwrite it in place at its current offset,
// truncating any leftover tail, then adopt the freshly written headers.
async #rewriteCentralDirectory() {
const { records, names } = this.#liveRecords();
const { centralHeaders, chunks } = buildCentralDirectoryChunks(
records, this.#centralDirectoryOffset, this.#comment);
let pos = this.#centralDirectoryOffset;
for (let i = 0; i < chunks.length; i++) {
await writeFdFully(this.#handle.fd, chunks[i], pos);
pos += chunks[i].length;
}
await fsFtruncateAsync(this.#handle.fd, pos);
this.#adoptRewrittenCentralDirectory(names, centralHeaders, records);
}
// Sync counterpart of #rewriteCentralDirectory().
#rewriteCentralDirectorySync() {
const { records, names } = this.#liveRecords();
const { centralHeaders, chunks } = buildCentralDirectoryChunks(
records, this.#centralDirectoryOffset, this.#comment);
let pos = this.#centralDirectoryOffset;
for (let i = 0; i < chunks.length; i++) {
writeFdFullySync(this.#handle.fd, chunks[i], pos);
pos += chunks[i].length;
}
fs.ftruncateSync(this.#handle.fd, pos);
this.#adoptRewrittenCentralDirectory(names, centralHeaders, records);
}
// Re-derives fresh, disk-backed central headers from what was just
// written, so every entry - original or freshly added - is uniformly
// readable by offset from now on, regardless of whether its in-memory
// ZipEntry (e.g. a streaming entry, whose source can only be consumed
// once) is still around.
#adoptRewrittenCentralDirectory(names, centralHeaders, records) {
for (let i = 0; i < names.length; i++) {
MapPrototypeSet(this.#entries, names[i], {
central: new CentralFileHeader(centralHeaders[i], 0),
entry: undefined,
localOffset: records[i].localOffset,
});
}
}
/**
* Serializes the currently live entries into a fresh archive stream,
* leaving behind any dead space left by prior `addEntry()`/`delete()`
* calls. Does not modify the open file; pipe the result into a new one.
* @param {string} [comment]
* @returns {import('stream').Readable}
*/
compact(comment) {
this.#assertOpen();
// Snapshot the live set now: a later addEntry()/delete() must not error
// out (or change) an archive stream that is already being produced.
// Reading the snapshot stays valid regardless of later mutations -
// neither addEntry() nor delete() moves existing member bytes.
const entries = [];
for (const { 1: info } of MapPrototypeEntries(this.#entries)) {
ArrayPrototypePush(entries, this.#handleFor(info));
}
return createZipArchive(entries, { comment: comment ?? this.#comment });
}
/**
* The synchronous counterpart of `compact()`. Blocks the event loop until
* the whole archive has been read and re-serialized; see the class-level
* note on synchronous methods.
* @param {string} [comment]
* @returns {Buffer}
*/
compactSync(comment) {
this.#assertOpen();
this.#assertNotBusy();
const entries = [];
for (const { 1: info } of MapPrototypeEntries(this.#entries)) {
ArrayPrototypePush(entries, this.#handleFor(info));
}
const chunks = [];
for (const chunk of createZipArchiveSync(entries, { comment: comment ?? this.#comment })) {
ArrayPrototypePush(chunks, chunk);
}
return Buffer.concat(chunks);
}
keys() { this.#assertOpen(); return MapPrototypeKeys(this.#entries); }
*values() {
for (const name of this.keys()) yield this.get(name);
}
/**
* The synchronous counterpart of `values()`, yielding resolved `ZipEntry`
* values instead of `Promise`s.
* @yields {ZipEntry}
*/
*valuesSync() {
for (const name of this.keys()) yield this.getSync(name);
}
*entries() {
for (const name of this.keys()) yield [name, this.get(name)];
}
/**
* The synchronous counterpart of `entries()`, yielding resolved `ZipEntry`
* values instead of `Promise`s.
* @yields {[string, ZipEntry]}
*/
*entriesSync() {
for (const name of this.keys()) yield [name, this.getSync(name)];
}
// Async iteration yields each resolved ZipEntry (awaiting the lazy handles).
async *[SymbolAsyncIterator]() {
for (const promise of this.values()) yield await promise;
}
get size() { this.#assertOpen(); return MapPrototypeGetSize(this.#entries); }
[SymbolIterator]() { return this.entries(); }
get [SymbolToStringTag]() { return 'ZipFile'; }
forEach(callback, thisArg) {
validateFunction(callback, 'callback');
for (const { 0: key, 1: value } of this.entries()) {
FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
}
}
/**
* The synchronous counterpart of `forEach()`, invoking `callback` with a
* resolved `ZipEntry` instead of a `Promise`.
* @param {Function} callback
* @param {*} [thisArg]
*/
forEachSync(callback, thisArg) {
validateFunction(callback, 'callback');
for (const { 0: key, 1: value } of this.entriesSync()) {
FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
}
}
// Drop all entries and close the fd, queued behind any pending async ops.
// Idempotent: a second close() is a safe no-op, never a double-close of a
// (possibly reused) descriptor. `#closing` is set synchronously so any
// operation issued after this point is rejected rather than racing the fd.
close() {
if (this.#closing) return this.#closePromise ?? PromiseResolve();
this.#closing = true;
// Block *new* reads immediately; reads already in flight keep the still-open
// fd until they finish (handle.closed, set below, is what stops mid-read).
this.#handle.closing = true;
this.#closePromise = this.#enqueue(async () => {
// Let reads already in flight through handed-out entries finish on the
// open fd before releasing it; endHandleRead() (entry.js) resolves this
// once the last one settles.
if (this.#handle.reads > 0) {
await new Promise((resolve) => { this.#handle.drain = resolve; });
}
this.#handle.closed = true;
MapPrototypeClear(this.#entries);
await fsCloseAsync(this.#handle.fd);
});
return this.#closePromise;
}
/**
* The synchronous counterpart of `close()`; see the class-level note on
* synchronous methods. Idempotent.
*/
closeSync() {
if (this.#closing) return;
this.#assertNotBusy();
// A synchronous close cannot wait for an async read the way close() does,
// so refuse rather than pull the fd out from under one still in flight.
if (this.#handle.reads > 0) {
throw new ERR_INVALID_STATE(
'cannot synchronously close a ZipFile while an asynchronous read is ' +
'still in flight');
}
this.#closing = true;
this.#handle.closing = true;
this.#handle.closed = true;
MapPrototypeClear(this.#entries);
fs.closeSync(this.#handle.fd);
}
async [SymbolAsyncDispose]() {
await this.close();
}
[SymbolDispose]() {
this.closeSync();
}
/**
* Opens an archive, reading only its tail and central directory up front
* (member content stays on disk). Pass `{ writable: true }` for in-place
* editing.
* @param {string} filename
* @param {{ writable?: boolean }} [options]
* @returns {Promise<ZipFile>}
*/
static async open(filename, options) {
validateString(filename, 'filename');
const writable = options?.writable ?? false;
validateBoolean(writable, 'options.writable');
const fd = await fsOpenAsync(filename, writable ? 'r+' : 'r');
try {
const stat = await fsFstatAsync(fd);
const size = stat.size;
const tailLength = MathMin(size, TAIL_LENGTH);
const tail = Buffer.allocUnsafe(tailLength);
await readFdFully(fd, tail, size - tailLength);
let end = findArchiveEnd(tail, size - tailLength);
if (end.needTailFrom !== undefined) {
// The Zip64 EOCD record's extensible data sector pushes the record
// start beyond the fixed-size tail; re-read from the recorded
// offset (bounded by ZIP64_EOCD_MAX_LENGTH inside findArchiveEnd).
const retry = Buffer.allocUnsafe(size - end.needTailFrom);
await readFdFully(fd, retry, end.needTailFrom);
end = findArchiveEnd(retry, end.needTailFrom);
if (end.needTailFrom !== undefined) {
throw new ERR_ZIP_INVALID_ARCHIVE(
'Zip64 end of central directory record not found');
}
}
checkArchiveEnd(end, size);
const directory = Buffer.allocUnsafe(end.centralDirectorySize);
await readFdFully(fd, directory, end.centralDirectoryOffset);
const headers = readCentralDirectory(directory, end.totalRecords);
await validateMemberBounds(fd, headers, end.prefix, size, end.centralDirectoryOffset);
return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable);
} catch (err) {
try {
await fsCloseAsync(fd);
} catch {
// The archive failed to parse; the close error is not actionable.
}
throw err;
}
}
/**
* The synchronous counterpart of `open()`. Blocks the event loop and
* further JavaScript execution until the archive's tail and central
* directory have been read; see the class-level note on synchronous
* methods.
* @param {string} filename
* @param {{ writable?: boolean }} [options]
* @returns {ZipFile}
*/
static openSync(filename, options) {
validateString(filename, 'filename');
const writable = options?.writable ?? false;
validateBoolean(writable, 'options.writable');
const fd = fs.openSync(filename, writable ? 'r+' : 'r');
try {
const size = fs.fstatSync(fd).size;
const tailLength = MathMin(size, TAIL_LENGTH);
const tail = Buffer.allocUnsafe(tailLength);
readFdFullySync(fd, tail, size - tailLength);
let end = findArchiveEnd(tail, size - tailLength);
if (end.needTailFrom !== undefined) {
// See open(): the Zip64 EOCD record starts before the fixed-size
// tail; re-read from the recorded offset.
const retry = Buffer.allocUnsafe(size - end.needTailFrom);
readFdFullySync(fd, retry, end.needTailFrom);
end = findArchiveEnd(retry, end.needTailFrom);
if (end.needTailFrom !== undefined) {
throw new ERR_ZIP_INVALID_ARCHIVE(
'Zip64 end of central directory record not found');
}
}
checkArchiveEnd(end, size);
const directory = Buffer.allocUnsafe(end.centralDirectorySize);
readFdFullySync(fd, directory, end.centralDirectoryOffset);
const headers = readCentralDirectory(directory, end.totalRecords);
validateMemberBoundsSync(fd, headers, end.prefix, size, end.centralDirectoryOffset);
return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable);
} catch (err) {
try {
fs.closeSync(fd);
} catch {
// The archive failed to parse; the close error is not actionable.
}
throw err;
}
}
}
module.exports = {
ZipFile,
};