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>
304 lines
9.9 KiB
JavaScript
304 lines
9.9 KiB
JavaScript
'use strict';
|
|
|
|
// Compression/decompression plumbing over the lazily-required `zlib` facade:
|
|
// one-shot (async and sync) and streaming deflate/inflate/zstd helpers, plus
|
|
// the member decoders that add method dispatch, size bounding, and CRC-32
|
|
// verification on top.
|
|
|
|
const {
|
|
JSONStringify,
|
|
MathMin,
|
|
Promise,
|
|
} = primordials;
|
|
|
|
const {
|
|
codes: {
|
|
ERR_ZIP_ENTRY_CORRUPT,
|
|
ERR_ZIP_ENTRY_TOO_LARGE,
|
|
ERR_ZIP_UNSUPPORTED_FEATURE,
|
|
},
|
|
} = require('internal/errors');
|
|
const { kMaxLength } = require('buffer');
|
|
const { compose } = require('stream');
|
|
const { crc32: crc32Native } = internalBinding('zlib');
|
|
const {
|
|
FLAG_ENCRYPTED,
|
|
METHOD_STORE,
|
|
METHOD_DEFLATE,
|
|
METHOD_ZSTD,
|
|
} = require('internal/zip/constants');
|
|
|
|
// `internal/zip` is required from `lib/zlib.js`, so it must not require the
|
|
// public `zlib` facade at load time (its module.exports is not yet
|
|
// populated). Compression is only needed once an entry is actually read or
|
|
// written, well after `zlib.js` has finished loading, so a lazy reference is
|
|
// enough to break the cycle.
|
|
let zlib;
|
|
function lazyZlib() {
|
|
zlib ??= require('zlib');
|
|
return zlib;
|
|
}
|
|
|
|
// -- compression plumbing ------------------------------------------------------
|
|
|
|
// The one-shot (async/sync) and streaming helpers below are thin adapters that
|
|
// promisify or stream-wrap the lazy `zlib` facade; individually trivial, they
|
|
// exist only so the rest of the module never touches `zlib` directly.
|
|
|
|
function deflateRawAsync(buffer) {
|
|
return new Promise((resolve, reject) => {
|
|
lazyZlib().deflateRaw(buffer, (err, result) => {
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function inflateRawAsync(buffer, options) {
|
|
return new Promise((resolve, reject) => {
|
|
lazyZlib().inflateRaw(buffer, options, (err, result) => {
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Drive `source` through a zlib transform stream, returning an async-iterable
|
|
// stream of its output. `compose()` (pipeline-backed) wires error propagation
|
|
// both ways and tears the whole chain down when the consumer errors or stops
|
|
// early, so an abandoned iteration cannot leak the pipeline.
|
|
function pumpThroughTransform(source, transform) {
|
|
return compose(source, transform);
|
|
}
|
|
|
|
function deflateRawStream(source) {
|
|
return pumpThroughTransform(source, lazyZlib().createDeflateRaw());
|
|
}
|
|
|
|
function inflateRawStream(source) {
|
|
return pumpThroughTransform(source, lazyZlib().createInflateRaw());
|
|
}
|
|
|
|
function zstdCompressAsync(buffer) {
|
|
return new Promise((resolve, reject) => {
|
|
lazyZlib().zstdCompress(buffer, (err, result) => {
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function zstdDecompressAsync(buffer, options) {
|
|
return new Promise((resolve, reject) => {
|
|
lazyZlib().zstdDecompress(buffer, options, (err, result) => {
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function zstdCompressStream(source) {
|
|
return pumpThroughTransform(source, lazyZlib().createZstdCompress());
|
|
}
|
|
|
|
function zstdDecompressStream(source) {
|
|
return pumpThroughTransform(source, lazyZlib().createZstdDecompress());
|
|
}
|
|
|
|
|
|
function deflateRawSync(buffer) {
|
|
return lazyZlib().deflateRawSync(buffer);
|
|
}
|
|
|
|
function zstdCompressSync(buffer) {
|
|
return lazyZlib().zstdCompressSync(buffer);
|
|
}
|
|
|
|
/**
|
|
* @typedef {{
|
|
* name: string,
|
|
* flags: number,
|
|
* method: number,
|
|
* crc32: number,
|
|
* uncompressedSize: number,
|
|
* }} ZipMemberInfo
|
|
*/
|
|
|
|
// Shared entry guards for the member decoders below: encryption and
|
|
// unsupported compression methods are rejected up front, and when the caller
|
|
// bounds the output, a declared size beyond that bound fails before anything
|
|
// is decompressed or allocated.
|
|
function assertDecodable(info, options) {
|
|
if (info.flags & FLAG_ENCRYPTED) {
|
|
throw new ERR_ZIP_UNSUPPORTED_FEATURE(
|
|
`entry ${JSONStringify(info.name)} is encrypted`);
|
|
}
|
|
if (info.method !== METHOD_STORE && info.method !== METHOD_DEFLATE && info.method !== METHOD_ZSTD) {
|
|
throw new ERR_ZIP_UNSUPPORTED_FEATURE(
|
|
`entry ${JSONStringify(info.name)} uses compression method ${info.method}`);
|
|
}
|
|
if (options?.maxSize !== undefined && info.uncompressedSize > options.maxSize) {
|
|
throw new ERR_ZIP_ENTRY_TOO_LARGE(
|
|
`entry ${JSONStringify(info.name)} declares ${info.uncompressedSize} bytes, ` +
|
|
`exceeding the ${options.maxSize} byte limit`);
|
|
}
|
|
}
|
|
|
|
// Bound one-shot decompression by the declared size, not just the caller's
|
|
// limit: a member that decompresses to more than it declares is corrupt by
|
|
// definition, so there is no reason to materialize more than `declared + 1`
|
|
// bytes (the +1 makes an overrun detectable) no matter how generous `maxSize`
|
|
// is. This keeps a tiny archive from forcing a `maxSize`-sized allocation.
|
|
function outputCap(info, options) {
|
|
return MathMin(
|
|
info.uncompressedSize + 1, options?.maxSize ?? kMaxLength, kMaxLength);
|
|
}
|
|
|
|
// Map a one-shot decompression failure to a corrupt-entry error.
|
|
// `assertDecodable()` has already ensured `declared <= maxSize`, so hitting
|
|
// the output cap always means the stream produced more than the member
|
|
// declared.
|
|
function rethrowDecodeFailure(err, info, method) {
|
|
if (err?.code === 'ERR_BUFFER_TOO_LARGE') {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} ` +
|
|
`${method === METHOD_DEFLATE ? 'inflates' : 'decompresses'} beyond its ` +
|
|
`declared size of ${info.uncompressedSize} bytes`);
|
|
}
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} failed to ` +
|
|
`${method === METHOD_DEFLATE ? 'inflate' : 'decompress'}: ${err.message}`);
|
|
}
|
|
|
|
// Enforce the declared size and (unless opted out) the CRC-32 on a fully
|
|
// decoded member.
|
|
function checkDecoded(data, info, verify) {
|
|
if (data.length !== info.uncompressedSize) {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` +
|
|
`${info.uncompressedSize}`);
|
|
}
|
|
if (verify && crc32Native(data, 0) !== info.crc32) {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} failed CRC-32 verification`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decodes one member's compressed byte stream: rejects encrypted entries and
|
|
* unsupported compression methods, inflates method 8 or decompresses method
|
|
* 93 (Zstandard), enforces the declared uncompressed size and verifies
|
|
* CRC-32 (on by default).
|
|
* @param {AsyncIterable<Buffer>} source
|
|
* @param {ZipMemberInfo} info
|
|
* @param {{ verify?: boolean, maxSize?: number }} [options]
|
|
* @yields {Buffer}
|
|
*/
|
|
async function* decodeMemberStream(source, info, options) {
|
|
assertDecodable(info, options);
|
|
const verify = options?.verify !== false;
|
|
let produced = 0;
|
|
let state = 0;
|
|
const decoded = info.method === METHOD_DEFLATE ? inflateRawStream(source) :
|
|
info.method === METHOD_ZSTD ? zstdDecompressStream(source) : source;
|
|
for await (const chunk of decoded) {
|
|
produced += chunk.length;
|
|
if (produced > info.uncompressedSize) {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} inflates beyond its declared size of ` +
|
|
`${info.uncompressedSize} bytes`);
|
|
}
|
|
if (verify) state = crc32Native(chunk, state);
|
|
yield chunk;
|
|
}
|
|
if (produced !== info.uncompressedSize) {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} is truncated: got ${produced} of ` +
|
|
`${info.uncompressedSize} bytes`);
|
|
}
|
|
if (verify && state !== info.crc32) {
|
|
throw new ERR_ZIP_ENTRY_CORRUPT(
|
|
`entry ${JSONStringify(info.name)} failed CRC-32 verification`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decodes one member held completely in `compressed`, in one shot: the same
|
|
* guards, declared-size bounding, and CRC-32 verification as
|
|
* `decodeMemberStream()`, returning the whole decoded member. For the store
|
|
* method the input buffer itself is returned - callers that must hand out
|
|
* caller-owned memory copy it (see `zipEntry.content()`).
|
|
* @param {Buffer} compressed
|
|
* @param {ZipMemberInfo} info
|
|
* @param {{ verify?: boolean, maxSize?: number }} [options]
|
|
* @returns {Promise<Buffer>}
|
|
*/
|
|
async function decodeMemberAsync(compressed, info, options) {
|
|
assertDecodable(info, options);
|
|
const cap = outputCap(info, options);
|
|
let data;
|
|
if (info.method === METHOD_DEFLATE) {
|
|
try {
|
|
data = await inflateRawAsync(compressed, { maxOutputLength: cap });
|
|
} catch (err) {
|
|
rethrowDecodeFailure(err, info, METHOD_DEFLATE);
|
|
}
|
|
} else if (info.method === METHOD_ZSTD) {
|
|
try {
|
|
data = await zstdDecompressAsync(compressed, { maxOutputLength: cap });
|
|
} catch (err) {
|
|
rethrowDecodeFailure(err, info, METHOD_ZSTD);
|
|
}
|
|
} else {
|
|
data = compressed;
|
|
}
|
|
checkDecoded(data, info, options?.verify !== false);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* The synchronous counterpart of `decodeMemberAsync()`. There is no public
|
|
* synchronous incremental inflate API, so - unlike the streaming path -
|
|
* `compressed` must already be the member's complete compressed byte
|
|
* stream, and the whole result is produced (and verified) in one call
|
|
* rather than yielded incrementally.
|
|
* @param {Buffer} compressed
|
|
* @param {ZipMemberInfo} info
|
|
* @param {{ verify?: boolean, maxSize?: number }} [options]
|
|
* @returns {Buffer}
|
|
*/
|
|
function decodeMemberSync(compressed, info, options) {
|
|
assertDecodable(info, options);
|
|
const cap = outputCap(info, options);
|
|
let data;
|
|
if (info.method === METHOD_DEFLATE) {
|
|
try {
|
|
data = lazyZlib().inflateRawSync(compressed, { maxOutputLength: cap });
|
|
} catch (err) {
|
|
rethrowDecodeFailure(err, info, METHOD_DEFLATE);
|
|
}
|
|
} else if (info.method === METHOD_ZSTD) {
|
|
try {
|
|
data = lazyZlib().zstdDecompressSync(compressed, { maxOutputLength: cap });
|
|
} catch (err) {
|
|
rethrowDecodeFailure(err, info, METHOD_ZSTD);
|
|
}
|
|
} else {
|
|
data = compressed;
|
|
}
|
|
checkDecoded(data, info, options?.verify !== false);
|
|
return data;
|
|
}
|
|
|
|
module.exports = {
|
|
deflateRawAsync,
|
|
deflateRawSync,
|
|
zstdCompressAsync,
|
|
zstdCompressSync,
|
|
deflateRawStream,
|
|
zstdCompressStream,
|
|
decodeMemberStream,
|
|
decodeMemberAsync,
|
|
decodeMemberSync,
|
|
};
|