Files
Philipp Dunkel 5ba72ae7bc zlib: add ZipEntry, ZipFile, and ZipBuffer
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>
2026-08-01 08:47:28 +00:00

187 lines
5.9 KiB
JavaScript

'use strict';
// `ZipBuffer`: an in-memory, writable view over the entries of an archive
// held in a `Buffer`, serializing the current set back out with
// `toBuffer()`/`toBufferSync()`.
const {
ArrayPrototypePush,
FunctionPrototypeCall,
Map,
MapPrototypeClear,
MapPrototypeDelete,
MapPrototypeEntries,
MapPrototypeGet,
MapPrototypeGetSize,
MapPrototypeHas,
MapPrototypeKeys,
MapPrototypeSet,
SymbolDispose,
SymbolIterator,
SymbolToStringTag,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_TYPE,
ERR_ZIP_ENTRY_NOT_FOUND,
},
} = require('internal/errors');
const {
validateFunction,
validateString,
} = require('internal/validators');
const { Buffer } = require('buffer');
const { toBuffer } = require('internal/zip/binary');
const { decodeZipText } = require('internal/zip/dos');
const { findArchiveEnd } = require('internal/zip/headers');
const {
readArchiveEntries,
ZipEntry,
} = require('internal/zip/entry');
const {
createZipArchive,
createZipArchiveSync,
normalizeArchiveOptions,
} = require('internal/zip/archive');
/**
* An in-memory view over the entries of a ZIP archive, writable in place:
* entries can be added or removed, and `toBuffer()` serializes the current
* set of entries into a fresh archive.
*/
class ZipBuffer {
#entries = new Map();
#comment;
/**
* Parses an existing archive's central directory into an in-memory,
* editable map of entries keyed by name.
* @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer
*/
constructor(buffer) {
const buf = toBuffer(buffer, 'buffer');
// Locate the archive end once; it supplies both the comment and the
// central-directory bounds for the entry walk.
const end = findArchiveEnd(buf);
this.#comment = end.comment;
for (const entry of readArchiveEntries(buf, end)) {
MapPrototypeSet(this.#entries, entry.name, entry);
}
}
get writable() { return true; }
// 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); }
has(name) {
validateString(name, 'name');
return MapPrototypeHas(this.#entries, name);
}
get(name) {
validateString(name, 'name');
const entry = MapPrototypeGet(this.#entries, name);
if (entry === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
return entry;
}
/**
* Adds an already-built entry, keyed by its own name (replacing any
* existing entry of that name).
* @param {ZipEntry} entry
* @returns {ZipEntry}
*/
addEntry(entry) {
if (!(entry instanceof ZipEntry)) {
throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
}
MapPrototypeSet(this.#entries, entry.name, entry);
return entry;
}
/**
* Builds an entry from in-memory `data` and adds it (replacing any entry of
* the same name).
* @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) {
return this.addEntry(await ZipEntry.create(filename, data, options));
}
/**
* The synchronous counterpart of `add()`. Blocks the event loop and
* further JavaScript execution until done; see `zipEntry.contentSync()`.
* @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) {
return this.addEntry(ZipEntry.createSync(filename, data, options));
}
/**
* @param {string} name
* @returns {boolean}
*/
delete(name) {
validateString(name, 'name');
return MapPrototypeDelete(this.#entries, name);
}
clear() {
MapPrototypeClear(this.#entries);
}
keys() { return MapPrototypeKeys(this.#entries); }
*values() {
for (const name of this.keys()) yield this.get(name);
}
*entries() {
for (const name of this.keys()) yield [name, this.get(name)];
}
get size() { return MapPrototypeGetSize(this.#entries); }
[SymbolIterator]() { return this.entries(); }
get [SymbolToStringTag]() { return 'ZipBuffer'; }
forEach(callback, thisArg) {
validateFunction(callback, 'callback');
for (const { 0: key, 1: value } of MapPrototypeEntries(this.#entries)) {
FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
}
}
/**
* Serializes the current set of entries into a fresh archive.
* @param {string | { comment?: string, baseOffset?: number }} [options]
* @returns {Promise<Buffer>}
*/
async toBuffer(options) {
const { comment, baseOffset } = normalizeArchiveOptions(options);
const chunks = [];
// Defaulting to the raw #comment bytes (not the decoded string)
// round-trips a non-UTF-8 archive comment unchanged.
for await (const chunk of createZipArchive(this.values(), { comment: comment ?? this.#comment, baseOffset })) {
ArrayPrototypePush(chunks, chunk);
}
return Buffer.concat(chunks);
}
/**
* The synchronous counterpart of `toBuffer()`. Blocks the event loop and
* further JavaScript execution until the whole archive has been
* serialized; see `zipEntry.contentSync()`.
* @param {string | { comment?: string, baseOffset?: number }} [options]
* @returns {Buffer}
*/
toBufferSync(options) {
const { comment, baseOffset } = normalizeArchiveOptions(options);
const chunks = [];
for (const chunk of createZipArchiveSync(this.values(), { comment: comment ?? this.#comment, baseOffset })) {
ArrayPrototypePush(chunks, chunk);
}
return Buffer.concat(chunks);
}
// Dispose: drop all entries (this view holds no fd of its own).
[SymbolDispose]() {
MapPrototypeClear(this.#entries);
}
}
module.exports = {
ZipBuffer,
};