current commit
260801.6 · zlib: add ZipEntry, ZipFile, and ZipBuffer
'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,
};